using System.Text.Json; using System.Text; using System.Threading.Channels; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Nexus.Api.Controllers; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class DomainEventOutboxTests { [Fact] public async Task Task_mutation_persists_content_minimized_outbox_event() { await using var db = CreateDatabase(); var repository = new TaskRepository(db); var task = new WorkTask { Title = "Sensitive title that must not enter the event payload", Detail = "Sensitive details", AssignedTo = "programmer" }; await repository.AddAsync(task); var created = await db.OutboxEvents.SingleAsync(); Assert.Equal("task.created", created.Type); Assert.Equal("task", created.AggregateType); Assert.Equal(task.Id.ToString(), created.AggregateId); Assert.DoesNotContain(task.Title, created.PayloadJson, StringComparison.Ordinal); Assert.DoesNotContain(task.Detail, created.PayloadJson, StringComparison.Ordinal); using var payload = JsonDocument.Parse(created.PayloadJson); Assert.Equal(task.Id, payload.RootElement.GetProperty("id").GetGuid()); Assert.Equal("Backlog", payload.RootElement.GetProperty("state").GetString()); task.State = "In progress"; await repository.UpdateAsync(task); var events = await db.OutboxEvents .OrderBy(item => item.Sequence) .ToListAsync(); Assert.Equal(2, events.Count); Assert.Equal("task.updated", events[1].Type); using var updatedPayload = JsonDocument.Parse(events[1].PayloadJson); Assert.Equal( "In progress", updatedPayload.RootElement.GetProperty("state").GetString()); } [Fact] public async Task Activity_event_keeps_message_content_out_of_domain_stream() { await using var db = CreateDatabase(); var repository = new ActivityRepository(db, new LiveUpdateService()); var taskId = Guid.NewGuid(); const string sensitiveMessage = "Agent programmer handled private workspace details"; await repository.AddAsync(new ActivityEvent { Type = "comment", Message = sensitiveMessage, TaskId = taskId }); var stored = await db.OutboxEvents.SingleAsync(); Assert.Equal("activity.created", stored.Type); Assert.Equal("activity", stored.AggregateType); Assert.DoesNotContain(sensitiveMessage, stored.PayloadJson, StringComparison.Ordinal); using var payload = JsonDocument.Parse(stored.PayloadJson); Assert.Equal(taskId, payload.RootElement.GetProperty("taskId").GetGuid()); Assert.Equal("comment", payload.RootElement.GetProperty("type").GetString()); } [Fact] public void Outbox_mapper_normalizes_entity_reference_and_clones_payload() { var stored = new OutboxEvent { Sequence = 42, Type = "agent.proposal.created", AggregateType = "AgentProposal", AggregateId = Guid.NewGuid().ToString(), AggregateRevision = 3, PayloadJson = """{"state":"awaiting_approval"}""" }; var mapped = DomainEventStreamService.Map(stored); Assert.Equal(42, mapped.Sequence); Assert.Equal("agent-proposal", mapped.Entity.Type); Assert.Equal(3, mapped.EntityRevision); Assert.Equal( "awaiting_approval", mapped.Payload.GetProperty("state").GetString()); } [Fact] public async Task Resync_required_advances_sse_cursor_to_current_sequence() { await using var db = CreateDatabase(); db.OutboxEvents.Add(new OutboxEvent { Sequence = 10, Type = "task.updated", AggregateType = "task", AggregateId = Guid.NewGuid().ToString(), PayloadJson = """{"state":"Backlog"}""", PublishedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); var stream = new FixedDomainEventStream(currentSequence: 25); var controller = new DomainEventsController( db, stream, NullLogger.Instance); var body = new MemoryStream(); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; controller.Response.Body = body; await controller.Get( channels: null, afterSequence: 1, CancellationToken.None); body.Position = 0; var content = await new StreamReader( body, Encoding.UTF8, leaveOpen: true).ReadToEndAsync(); Assert.Contains("id: 25", content, StringComparison.Ordinal); Assert.Contains("\"eventType\":\"resync_required\"", content, StringComparison.Ordinal); Assert.Contains("\"resumeSequence\":25", content, StringComparison.Ordinal); } [Fact] public async Task Replay_exposes_only_events_committed_as_published() { await using var db = CreateDatabase(); var entityId = Guid.NewGuid().ToString(); db.OutboxEvents.AddRange( new OutboxEvent { Sequence = 1, Type = "task.updated", AggregateType = "task", AggregateId = entityId, PayloadJson = """{"state":"Review"}""", PublishedAt = DateTimeOffset.UtcNow }, new OutboxEvent { Sequence = 2, Type = "task.updated", AggregateType = "task", AggregateId = entityId, PayloadJson = """{"state":"Done"}""" }); await db.SaveChangesAsync(); var controller = new DomainEventsController( db, new FixedDomainEventStream(currentSequence: 2), NullLogger.Instance); var body = new MemoryStream(); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; controller.Response.Body = body; await controller.Get( channels: null, afterSequence: 0, CancellationToken.None); body.Position = 0; var content = await new StreamReader( body, Encoding.UTF8, leaveOpen: true).ReadToEndAsync(); Assert.Contains("id: 1", content, StringComparison.Ordinal); Assert.Contains("\"state\":\"Review\"", content, StringComparison.Ordinal); Assert.DoesNotContain("id: 2", content, StringComparison.Ordinal); Assert.DoesNotContain("\"state\":\"Done\"", content, StringComparison.Ordinal); } private static NexusDbContext CreateDatabase() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"domain-outbox-{Guid.NewGuid():N}") .Options; return new NexusDbContext(options); } private sealed class FixedDomainEventStream(long currentSequence) : IDomainEventStreamService { public long CurrentSequence { get; } = currentSequence; public DomainEventSubscription Subscribe(IReadOnlySet channels) { var channel = Channel.CreateUnbounded(); channel.Writer.TryComplete(); return new DomainEventSubscription( channel.Reader, CurrentSequence, () => ValueTask.CompletedTask); } } }