books/backend/Books.Api.Tests/Integration/ReadModelRepopulationTests.cs

420 lines
16 KiB
C#
Raw Normal View History

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
}