using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.Automation; /// Every mutation this milestone wires into the outbox must produce a /// durable, correctly-typed row in the same transaction as the mutation itself /// (docs/24-mcp-readiness.md "Outbox events", M6-A06) — proven here by reading the /// table directly, the same "no consumer exists yet, but the record must be there and /// correct for one later" contract the outbox is designed around. [Collection(PostgresTestGroup.Name)] public sealed class OutboxTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Creating_a_page_records_a_page_created_outbox_event_in_the_same_transaction() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Outbox-testpagina", "WikiPage", "Inhoud.", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var messages = await db.OutboxMessages .Where(message => message.EventType == "page.created.v1" && message.AggregateId == page.Id.ToString()) .ToListAsync(); var message = Assert.Single(messages); Assert.False(message.IsProcessed); Assert.Contains(page.Id.ToString(), message.PayloadJson, StringComparison.Ordinal); } [Fact] public async Task Publishing_a_page_edit_records_a_page_updated_outbox_event() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Bij te werken pagina", "WikiPage", "Inhoud.", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); await SetupHelper.PutWithCsrfAsync( client, $"/api/v1/pages/{page.Id}", new UpdatePageRequest("Bijgewerkte titel", "Nieuwe inhoud", "WikiPage", "Active", null), page.ETag); using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var messages = await db.OutboxMessages .Where(message => message.EventType == "page.updated.v1" && message.AggregateId == page.Id.ToString()) .ToListAsync(); Assert.Single(messages); } [Fact] public async Task Unprocessed_outbox_messages_are_queryable_oldest_first_a_future_dispatcher_could_replay_them() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 1", "WikiPage", "I.", null)); await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 2", "WikiPage", "I.", null)); using var scope = Factory.Services.CreateScope(); var outbox = scope.ServiceProvider.GetRequiredService(); var unprocessed = await outbox.GetUnprocessedAsync(500, CancellationToken.None); Assert.True(unprocessed.Count >= 2); Assert.All(unprocessed, message => Assert.False(message.IsProcessed)); // Oldest first: monotonically non-decreasing AvailableAt. for (var i = 1; i < unprocessed.Count; i++) { Assert.True(unprocessed[i].AvailableAt >= unprocessed[i - 1].AvailableAt); } } }