using System.Reflection; using System.Text; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Nexus.Api.Controllers; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawEventProjectionTests { [Fact] public void Project_OrdersEvents_RedactsSecrets_AndReportsSequenceGap() { var now = DateTimeOffset.UtcNow; var connector = new EventConnector { Events = [ new GatewayEventEnvelope( "chat", new JsonObject { ["runId"] = "run-1", ["state"] = "delta", ["seq"] = 2, ["apiToken"] = "do-not-leak", ["inputTokens"] = 42 }, 12, 4, now.AddMilliseconds(20)), new GatewayEventEnvelope( "sessions.changed", new JsonObject { ["sessionKey"] = "agent:iris:main" }, 10, 3, now) ] }; var service = new OpenClawEventProjectionService(connector); var batch = service.Project(null); Assert.Equal(2, batch.Events.Count); Assert.Equal("sessions.changed", batch.Events[0].EventName); Assert.Equal("chat", batch.Events[1].EventName); Assert.True(batch.Events[1].SequenceGapDetected); Assert.Equal(11, batch.Events[1].MissingSequenceFrom); Assert.Equal(11, batch.Events[1].MissingSequenceTo); Assert.Equal("[redacted]", batch.Events[1].Payload?["apiToken"]?.GetValue()); Assert.Equal(42, batch.Events[1].Payload?["inputTokens"]?.GetValue()); Assert.StartsWith("gw-", batch.Events[0].Id); Assert.Equal(batch.Events[^1].Id, batch.Cursor); } [Fact] public void Project_ReplaysAfterKnownCursor_AndSignalsExpiredCursor() { var now = DateTimeOffset.UtcNow; var connector = new EventConnector { Events = [ Event("chat", 3, now.AddSeconds(2)), Event("session.tool", 2, now.AddSeconds(1)), Event("sessions.changed", 1, now) ] }; var service = new OpenClawEventProjectionService(connector); var initial = service.Project(null); var replay = service.Project(initial.Events[0].Id); var expired = service.Project("gw-expired"); Assert.Equal(2, replay.Events.Count); Assert.False(replay.ReplayBoundaryMissed); Assert.True(expired.ReplayBoundaryMissed); Assert.Equal(3, expired.Events.Count); Assert.Equal(initial.Events[^1].Id, expired.Cursor); } [Fact] public void Project_SignalsExpiredCursorAfterBackendRestartWithEmptyBuffer() { var service = new OpenClawEventProjectionService(new EventConnector()); var batch = service.Project("gw-from-previous-process"); Assert.True(batch.ReplayBoundaryMissed); Assert.Empty(batch.Events); Assert.Equal("origin", batch.Cursor); } [Fact] public void Project_ReportsOuterSequenceResetWithoutCallingItAMissingRange() { var now = DateTimeOffset.UtcNow; var connector = new EventConnector { Events = [ Event("chat", 1, now.AddSeconds(1)), Event("chat", 80, now) ] }; var service = new OpenClawEventProjectionService(connector); var batch = service.Project(null); Assert.True(batch.Events[1].SequenceResetDetected); Assert.False(batch.Events[1].SequenceGapDetected); Assert.Equal(80, batch.Events[1].PreviousSequence); Assert.Equal(1, batch.Events[1].Sequence); } [Theory] [InlineData("sessions.changed", "session")] [InlineData("session.tool", "tool")] [InlineData("exec.approval.requested", "approval")] [InlineData("artifact.created", "artifact")] [InlineData("chat", "run")] public void Project_ClassifiesOperationalEventFamilies(string eventName, string category) { var connector = new EventConnector { Events = [Event(eventName, 1, DateTimeOffset.UtcNow)] }; var item = Assert.Single(new OpenClawEventProjectionService(connector).Project(null).Events); Assert.Equal(category, item.Category); Assert.Equal($"openclaw.{category}", item.Type); } [Fact] public async Task Controller_UsesLastEventId_AndCanReturnFiniteReplay() { var connector = new EventConnector { Events = [Event("chat", 7, DateTimeOffset.UtcNow)] }; var projector = new OpenClawEventProjectionService(connector); var controller = new OpenClawEventsController(projector); var context = new DefaultHttpContext(); context.Request.Headers["Last-Event-ID"] = "gw-expired"; context.Response.Body = new MemoryStream(); controller.ControllerContext = new ControllerContext { HttpContext = context }; await controller.Stream(follow: false); context.Response.Body.Position = 0; using var reader = new StreamReader(context.Response.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); Assert.Equal("text/event-stream", context.Response.ContentType); Assert.Contains("event: openclaw.connection", body, StringComparison.Ordinal); Assert.Contains("event: openclaw.gap", body, StringComparison.Ordinal); Assert.Contains("event: openclaw.run", body, StringComparison.Ordinal); Assert.Contains("event: openclaw.heartbeat", body, StringComparison.Ordinal); Assert.Contains("last-event-id-outside-buffer", body, StringComparison.Ordinal); } [Fact] public void Controller_IsAuthenticated() { var authorize = typeof(OpenClawEventsController) .GetCustomAttributes() .SingleOrDefault(); Assert.NotNull(authorize); } private static GatewayEventEnvelope Event( string name, long sequence, DateTimeOffset receivedAt) => new( name, new JsonObject { ["runId"] = "run-1", ["state"] = "delta", ["seq"] = sequence }, sequence, sequence, receivedAt); private sealed class EventConnector : IGatewayConnector { public IReadOnlyList Events { get; init; } = []; public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected; public string? GatewayVersion => "2026.7.0"; public string? RequiredVersion => "2026.7.0"; public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow; public int ReconnectAttempts => 0; public string? StatusMessage => "Connected"; public string? DeviceId => "nexus-test"; public bool DeviceTokenConfigured => true; public bool PairingRequired => false; public string? PairingRequestId => null; public int? ProtocolVersion => 4; public IReadOnlySet AdvertisedMethods { get; } = new HashSet(); public IReadOnlySet AdvertisedEvents { get; } = new HashSet(); public IReadOnlySet GrantedScopes { get; } = new HashSet(); public DateTimeOffset? LastEventAt => Events.FirstOrDefault()?.ReceivedAt; public bool Supports(string method) => AdvertisedMethods.Contains(method); public Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) => Task.FromResult(null); public IReadOnlyList GetRecentEvents(int limit = 100) => Events.Take(limit).ToList(); } }