223 lines
7.8 KiB
C#
223 lines
7.8 KiB
C#
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<DomainEventsController>.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<DomainEventsController>.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<NexusDbContext>()
|
|
.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<string> channels)
|
|
{
|
|
var channel = Channel.CreateUnbounded<DomainEventDto>();
|
|
channel.Writer.TryComplete();
|
|
return new DomainEventSubscription(
|
|
channel.Reader,
|
|
CurrentSequence,
|
|
() => ValueTask.CompletedTask);
|
|
}
|
|
}
|
|
}
|