420 lines
16 KiB
C#
420 lines
16 KiB
C#
|
|
using Books.Api.EventFlow.Infrastructure;
|
||
|
|
using Books.Api.EventFlow.ReadModels;
|
||
|
|
using Books.Api.EventFlow.Repositories;
|
||
|
|
using Books.Api.Tests.Helpers;
|
||
|
|
using Books.Api.Tests.Infrastructure;
|
||
|
|
using Dapper;
|
||
|
|
using AwesomeAssertions;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Npgsql;
|
||
|
|
|
||
|
|
namespace Books.Api.Tests.Integration;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Integration tests for the read model auto-repair/repopulation system.
|
||
|
|
/// Tests verify that corrupt or out-of-sync read models can be repaired
|
||
|
|
/// by replaying events from the event store.
|
||
|
|
/// </summary>
|
||
|
|
[Trait("Category", "Integration")]
|
||
|
|
public class ReadModelRepopulationTests(TestWebApplicationFactory factory)
|
||
|
|
: IntegrationTestBase(factory)
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_FixesCorruptedStatus()
|
||
|
|
{
|
||
|
|
// Arrange: Create a draft through normal flow
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Repopulation Test Company");
|
||
|
|
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Test Draft for Repopulation");
|
||
|
|
|
||
|
|
// Wait for read model to be created
|
||
|
|
var originalDraft = await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
return await repo.GetByIdAsync(draftId);
|
||
|
|
});
|
||
|
|
|
||
|
|
originalDraft.Should().NotBeNull();
|
||
|
|
originalDraft!.Status.Should().Be("active");
|
||
|
|
|
||
|
|
// Corrupt the read model: Set status to something wrong
|
||
|
|
await CorruptReadModelStatusAsync(draftId, "posted");
|
||
|
|
|
||
|
|
// Verify corruption
|
||
|
|
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
corruptedDraft!.Status.Should().Be("posted"); // Corrupted!
|
||
|
|
|
||
|
|
// Act: Repopulate the read model
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = draftId,
|
||
|
|
readModelType = "JournalEntryDraftReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert: Repopulation succeeded
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
response.Data!.RepopulateReadModel.Should().BeTrue();
|
||
|
|
|
||
|
|
// Verify read model is now fixed
|
||
|
|
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
fixedDraft!.Status.Should().Be("active"); // Fixed!
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_FixesMissingName()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Missing Name Test Company");
|
||
|
|
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Draft Name");
|
||
|
|
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
return await repo.GetByIdAsync(draftId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Corrupt: Clear the name
|
||
|
|
await CorruptReadModelNameAsync(draftId, "");
|
||
|
|
|
||
|
|
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
corruptedDraft!.Name.Should().BeEmpty();
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = draftId,
|
||
|
|
readModelType = "JournalEntryDraftReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
fixedDraft!.Name.Should().Be("Original Draft Name");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_RestoresUpdatedData()
|
||
|
|
{
|
||
|
|
// Arrange: Create and update a draft
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Updated Data Test Company");
|
||
|
|
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Initial Name");
|
||
|
|
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
return await repo.GetByIdAsync(draftId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update the draft with new data
|
||
|
|
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
|
||
|
|
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
|
||
|
|
updateJournalEntryDraft(input: $input) { id name description }
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
input = new
|
||
|
|
{
|
||
|
|
id = draftId,
|
||
|
|
name = "Updated Name",
|
||
|
|
description = "Test Description",
|
||
|
|
documentDate = "2025-01-15",
|
||
|
|
lines = Array.Empty<object>()
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Wait for update
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
var draft = await repo.GetByIdAsync(draftId);
|
||
|
|
return draft?.Name == "Updated Name" ? draft : null;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Corrupt: Revert to old data
|
||
|
|
await CorruptReadModelNameAsync(draftId, "Wrong Name");
|
||
|
|
await CorruptReadModelDescriptionAsync(draftId, "Wrong Description");
|
||
|
|
|
||
|
|
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
corruptedDraft!.Name.Should().Be("Wrong Name");
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = draftId,
|
||
|
|
readModelType = "JournalEntryDraftReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
fixedDraft!.Name.Should().Be("Updated Name");
|
||
|
|
fixedDraft.Description.Should().Be("Test Description");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_FixesOutOfSyncSequenceNumber()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Sequence Number Test Company");
|
||
|
|
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Sequence Test Draft");
|
||
|
|
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
return await repo.GetByIdAsync(draftId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update draft multiple times to increase sequence number
|
||
|
|
for (int i = 0; i < 3; i++)
|
||
|
|
{
|
||
|
|
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
|
||
|
|
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
|
||
|
|
updateJournalEntryDraft(input: $input) { id }
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
input = new
|
||
|
|
{
|
||
|
|
id = draftId,
|
||
|
|
name = $"Update {i + 1}",
|
||
|
|
lines = Array.Empty<object>()
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await Task.Delay(100);
|
||
|
|
}
|
||
|
|
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
var draft = await repo.GetByIdAsync(draftId);
|
||
|
|
return draft?.Name == "Update 3" ? draft : null;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Corrupt: Set sequence number to 1 (old)
|
||
|
|
await CorruptReadModelSequenceNumberAsync(draftId, 1);
|
||
|
|
await CorruptReadModelNameAsync(draftId, "Old Name");
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = draftId,
|
||
|
|
readModelType = "JournalEntryDraftReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
|
||
|
|
fixedDraft!.Name.Should().Be("Update 3"); // Latest name from events
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_FailsForNonExistentAggregate()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Aggregate Company");
|
||
|
|
var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}";
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = nonExistentId,
|
||
|
|
readModelType = "JournalEntryDraftReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert: Should fail or succeed without effect (no events to replay)
|
||
|
|
// The behavior depends on implementation - either error or silent success
|
||
|
|
// Since there are no events, it should complete but read model won't exist
|
||
|
|
if (!response.HasErrors)
|
||
|
|
{
|
||
|
|
var draft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(nonExistentId);
|
||
|
|
draft.Should().BeNull();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RepopulateReadModel_FailsForInvalidReadModelType()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Type Company");
|
||
|
|
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Invalid Type Test");
|
||
|
|
|
||
|
|
await Eventually.GetAsync(async () =>
|
||
|
|
{
|
||
|
|
var repo = GetService<IJournalEntryDraftRepository>();
|
||
|
|
return await repo.GetByIdAsync(draftId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Act
|
||
|
|
var response = await graphqlClient.MutateAsync<RepopulateNullableResponse>("""
|
||
|
|
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
|
||
|
|
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
aggregateId = draftId,
|
||
|
|
readModelType = "NonExistentReadModel"
|
||
|
|
});
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.HasErrors.Should().BeTrue();
|
||
|
|
var errorDetails = response.Errors!
|
||
|
|
.SelectMany(e => new[] { e.Message, e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "" });
|
||
|
|
var errorInfo = string.Join(" ", errorDetails);
|
||
|
|
errorInfo.Should().Contain("Unknown read model type");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task ListReadModelTypes_ReturnsAvailableTypes()
|
||
|
|
{
|
||
|
|
// Arrange
|
||
|
|
var graphqlClient = new GraphQLTestClient(Client);
|
||
|
|
var companyId = await CreateCompanyAsync(graphqlClient, "List Types Company");
|
||
|
|
|
||
|
|
// Act - listReadModelTypes is a mutation field (admin endpoint)
|
||
|
|
var response = await graphqlClient.MutateAsync<ListTypesResponse>("""
|
||
|
|
mutation {
|
||
|
|
listReadModelTypes
|
||
|
|
}
|
||
|
|
""");
|
||
|
|
|
||
|
|
// Assert
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
response.Data!.ListReadModelTypes.Should().Contain("JournalEntryDraftReadModel");
|
||
|
|
response.Data.ListReadModelTypes.Should().Contain("AccountReadModel");
|
||
|
|
response.Data.ListReadModelTypes.Should().Contain("FiscalYearReadModel");
|
||
|
|
response.Data.ListReadModelTypes.Should().Contain("CompanyReadModel");
|
||
|
|
}
|
||
|
|
|
||
|
|
#region Database Corruption Helpers
|
||
|
|
|
||
|
|
private async Task CorruptReadModelStatusAsync(string aggregateId, string newStatus)
|
||
|
|
{
|
||
|
|
var dataSource = GetService<NpgsqlDataSource>();
|
||
|
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
|
await conn.ExecuteAsync(
|
||
|
|
"UPDATE journal_entry_draft_read_models SET status = @status WHERE aggregate_id = @id",
|
||
|
|
new { status = newStatus, id = aggregateId });
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task CorruptReadModelNameAsync(string aggregateId, string newName)
|
||
|
|
{
|
||
|
|
var dataSource = GetService<NpgsqlDataSource>();
|
||
|
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
|
await conn.ExecuteAsync(
|
||
|
|
"UPDATE journal_entry_draft_read_models SET name = @name WHERE aggregate_id = @id",
|
||
|
|
new { name = newName, id = aggregateId });
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task CorruptReadModelDescriptionAsync(string aggregateId, string newDescription)
|
||
|
|
{
|
||
|
|
var dataSource = GetService<NpgsqlDataSource>();
|
||
|
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
|
await conn.ExecuteAsync(
|
||
|
|
"UPDATE journal_entry_draft_read_models SET description = @description WHERE aggregate_id = @id",
|
||
|
|
new { description = newDescription, id = aggregateId });
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task CorruptReadModelSequenceNumberAsync(string aggregateId, int newSequenceNumber)
|
||
|
|
{
|
||
|
|
var dataSource = GetService<NpgsqlDataSource>();
|
||
|
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
|
await conn.ExecuteAsync(
|
||
|
|
"UPDATE journal_entry_draft_read_models SET last_aggregate_sequence_number = @seq WHERE aggregate_id = @id",
|
||
|
|
new { seq = newSequenceNumber, id = aggregateId });
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Helper Methods
|
||
|
|
|
||
|
|
private async Task<string> CreateCompanyAsync(GraphQLTestClient client, string name)
|
||
|
|
{
|
||
|
|
var response = await client.MutateAsync<CreateCompanyResponse>("""
|
||
|
|
mutation CreateCompany($input: CreateCompanyInput!) {
|
||
|
|
createCompany(input: $input) { id }
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new { input = new { name } });
|
||
|
|
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
var companyId = response.Data!.CreateCompany!.Id;
|
||
|
|
client.SetCompanyId(companyId);
|
||
|
|
return companyId;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<string> CreateDraftAsync(GraphQLTestClient client, string companyId, string name)
|
||
|
|
{
|
||
|
|
var response = await client.MutateAsync<CreateDraftResponse>("""
|
||
|
|
mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
|
||
|
|
createJournalEntryDraft(input: $input) { id }
|
||
|
|
}
|
||
|
|
""",
|
||
|
|
new
|
||
|
|
{
|
||
|
|
input = new
|
||
|
|
{
|
||
|
|
companyId,
|
||
|
|
name
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
response.EnsureNoErrors();
|
||
|
|
return response.Data!.CreateJournalEntryDraft!.Id;
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Response DTOs
|
||
|
|
|
||
|
|
private class RepopulateResponse { public bool RepopulateReadModel { get; set; } }
|
||
|
|
private class RepopulateNullableResponse { public bool? RepopulateReadModel { get; set; } }
|
||
|
|
private class ListTypesResponse { public List<string> ListReadModelTypes { get; set; } = []; }
|
||
|
|
private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
|
||
|
|
private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } }
|
||
|
|
private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } }
|
||
|
|
|
||
|
|
private class CompanyDto { public string Id { get; set; } = string.Empty; }
|
||
|
|
private class DraftDto
|
||
|
|
{
|
||
|
|
public string Id { get; set; } = string.Empty;
|
||
|
|
public string Name { get; set; } = string.Empty;
|
||
|
|
public string? Description { get; set; }
|
||
|
|
public string Status { get; set; } = string.Empty;
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
}
|