Files
nexus/backend-tests/OpenClawEventProjectionTests.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

225 lines
8.1 KiB
C#

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<string>());
Assert.Equal(42, batch.Events[1].Payload?["inputTokens"]?.GetValue<int>());
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<AuthorizeAttribute>()
.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<GatewayEventEnvelope> 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<string> AdvertisedMethods { get; } = new HashSet<string>();
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
public DateTimeOffset? LastEventAt => Events.FirstOrDefault()?.ReceivedAt;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Task.FromResult<JsonNode?>(null);
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
=> Events.Take(limit).ToList();
}
}