feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user