// Copyright (c) StellaOps. Licensed under the BUSL-1.1. using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Moq; using StellaOps.Eventing.Models; using StellaOps.Eventing.Storage; using StellaOps.HybridLogicalClock; using Xunit; namespace StellaOps.Eventing.Tests; [Trait("Category", "Unit")] public sealed class TimelineEventEmitterTests { private readonly FakeTimeProvider _timeProvider; private readonly Mock _hlcMock; private readonly InMemoryTimelineEventStore _eventStore; private readonly IOptions _options; private readonly TimelineEventEmitter _emitter; public TimelineEventEmitterTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero)); _hlcMock = new Mock(); _eventStore = new InMemoryTimelineEventStore(); _options = Options.Create(new EventingOptions { ServiceName = "TestService", EngineVersion = new EngineVersionRef("TestEngine", "1.0.0", "sha256:test") }); _emitter = new TimelineEventEmitter( _hlcMock.Object, _timeProvider, _eventStore, _options, NullLogger.Instance); } private static HlcTimestamp CreateHlc(long physicalTime, int logicalCounter, string nodeId) { return new HlcTimestamp { PhysicalTime = physicalTime, LogicalCounter = logicalCounter, NodeId = nodeId }; } [Fact] public async Task EmitAsync_StoresEventWithCorrectFields() { // Arrange var ct = TestContext.Current.CancellationToken; var correlationId = "scan-abc123"; var kind = EventKinds.Enqueue; var payload = new { JobId = "job-1", Status = "pending" }; var expectedHlc = CreateHlc(1704585600000, 0, "node1"); _hlcMock.Setup(h => h.Tick()).Returns(expectedHlc); // Act var result = await _emitter.EmitAsync(correlationId, kind, payload, ct); // Assert result.Should().NotBeNull(); result.CorrelationId.Should().Be(correlationId); result.Kind.Should().Be(kind); result.Service.Should().Be("TestService"); result.THlc.Should().Be(expectedHlc); result.TsWall.Should().Be(_timeProvider.GetUtcNow()); result.SchemaVersion.Should().Be(1); result.EngineVersion.EngineName.Should().Be("TestEngine"); result.EngineVersion.Version.Should().Be("1.0.0"); result.PayloadDigest.Should().NotBeEmpty(); result.EventId.Should().HaveLength(32); } [Fact] public async Task EmitAsync_GeneratesDeterministicEventId() { // Arrange var ct = TestContext.Current.CancellationToken; var correlationId = "scan-abc123"; var kind = EventKinds.Execute; var payload = new { Step = 1 }; var hlc = CreateHlc(1704585600000, 0, "node1"); _hlcMock.Setup(h => h.Tick()).Returns(hlc); // Act var result1 = await _emitter.EmitAsync(correlationId, kind, payload, ct); // Create a second emitter with same config var emitter2 = new TimelineEventEmitter( _hlcMock.Object, _timeProvider, new InMemoryTimelineEventStore(), _options, NullLogger.Instance); var result2 = await emitter2.EmitAsync(correlationId, kind, payload, ct); // Assert - Same inputs should produce same EventId result1.EventId.Should().Be(result2.EventId); } [Fact] public async Task EmitAsync_StoresEventInStore() { // Arrange var ct = TestContext.Current.CancellationToken; var correlationId = "scan-abc123"; var hlc = CreateHlc(1704585600000, 0, "node1"); _hlcMock.Setup(h => h.Tick()).Returns(hlc); // Act var emitted = await _emitter.EmitAsync(correlationId, EventKinds.Enqueue, new { Test = true }, ct); // Assert var stored = await _eventStore.GetByIdAsync(emitted.EventId, ct); stored.Should().NotBeNull(); stored!.EventId.Should().Be(emitted.EventId); } [Fact] public async Task EmitBatchAsync_StoresAllEvents() { // Arrange var ct = TestContext.Current.CancellationToken; var hlcCounter = 0; _hlcMock.Setup(h => h.Tick()) .Returns(() => CreateHlc(1704585600000, hlcCounter++, "node1")); var pendingEvents = new[] { new PendingEvent("scan-1", EventKinds.Enqueue, new { Step = 1 }), new PendingEvent("scan-1", EventKinds.Execute, new { Step = 2 }), new PendingEvent("scan-1", EventKinds.Complete, new { Step = 3 }) }; // Act var results = await _emitter.EmitBatchAsync(pendingEvents, ct); // Assert results.Should().HaveCount(3); results.Select(r => r.Kind).Should().BeEquivalentTo( new[] { EventKinds.Enqueue, EventKinds.Execute, EventKinds.Complete }); var stored = _eventStore.GetAll(); stored.Should().HaveCount(3); } [Fact] public async Task EmitBatchAsync_EmptyBatch_ReturnsEmptyList() { // Act var ct = TestContext.Current.CancellationToken; var results = await _emitter.EmitBatchAsync(Array.Empty(), ct); // Assert results.Should().BeEmpty(); } [Fact] public async Task EmitAsync_IncludesPayloadDigest() { // Arrange var ct = TestContext.Current.CancellationToken; var hlc = CreateHlc(1704585600000, 0, "node1"); _hlcMock.Setup(h => h.Tick()).Returns(hlc); // Act var result = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Data = "test" }, ct); // Assert result.PayloadDigest.Should().NotBeNull(); result.PayloadDigest.Should().HaveCount(32); // SHA-256 } [Fact] public async Task EmitAsync_DifferentPayloads_DifferentDigests() { // Arrange var ct = TestContext.Current.CancellationToken; var hlcCounter = 0; _hlcMock.Setup(h => h.Tick()) .Returns(() => CreateHlc(1704585600000, hlcCounter++, "node1")); // Act var result1 = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Value = 1 }, ct); var result2 = await _emitter.EmitAsync("corr-1", EventKinds.Emit, new { Value = 2 }, ct); // Assert result1.PayloadDigest.Should().NotBeEquivalentTo(result2.PayloadDigest); } }