using Microsoft.Extensions.Logging.Abstractions; using StellaOps.TaskRunner.Core.Events; using Xunit; using StellaOps.TestKit; namespace StellaOps.TaskRunner.Tests; /// /// Tests for pack run timeline event domain model, emitter, and sink. /// Per TASKRUN-OBS-52-001. /// public sealed class PackRunTimelineEventTests { private const string TestTenantId = "test-tenant"; private const string TestRunId = "run-12345"; private const string TestPlanHash = "sha256:abc123"; private const string TestStepId = "step-001"; private const string TestProjectId = "project-xyz"; #region Domain Model Tests [Trait("Category", TestCategories.Unit)] [Fact] public void Create_WithRequiredFields_GeneratesValidEvent() { // Arrange var occurredAt = DateTimeOffset.UtcNow; // Act var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: occurredAt, runId: TestRunId, planHash: TestPlanHash); // Assert Assert.NotEqual(Guid.Empty, evt.EventId); Assert.Equal(TestTenantId, evt.TenantId); Assert.Equal(PackRunEventTypes.PackStarted, evt.EventType); Assert.Equal("taskrunner-worker", evt.Source); Assert.Equal(occurredAt, evt.OccurredAt); Assert.Equal(TestRunId, evt.RunId); Assert.Equal(TestPlanHash, evt.PlanHash); Assert.Null(evt.ReceivedAt); Assert.Null(evt.EventSeq); } [Trait("Category", TestCategories.Unit)] [Fact] public void Create_WithPayload_ComputesHashAndNormalizes() { // Arrange var payload = new { stepId = "step-001", attempt = 1 }; // Act var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, payload: payload); // Assert Assert.NotNull(evt.RawPayloadJson); Assert.NotNull(evt.NormalizedPayloadJson); Assert.NotNull(evt.PayloadHash); Assert.StartsWith("sha256:", evt.PayloadHash); Assert.Equal(64 + 7, evt.PayloadHash.Length); // sha256: prefix + 64 hex chars } [Trait("Category", TestCategories.Unit)] [Fact] public void Create_WithStepId_SetsStepId() { // Act var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepCompleted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, stepId: TestStepId); // Assert Assert.Equal(TestStepId, evt.StepId); } [Trait("Category", TestCategories.Unit)] [Fact] public void Create_WithEvidencePointer_SetsPointer() { // Arrange var evidence = PackRunEvidencePointer.Bundle(Guid.NewGuid(), "sha256:def456"); // Act var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackCompleted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, evidencePointer: evidence); // Assert Assert.NotNull(evt.EvidencePointer); Assert.Equal(PackRunEvidencePointerType.Bundle, evt.EvidencePointer.Type); Assert.Equal("sha256:def456", evt.EvidencePointer.BundleDigest); } [Trait("Category", TestCategories.Unit)] [Fact] public void WithReceivedAt_CreatesCopyWithTimestamp() { // Arrange var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash); var receivedAt = DateTimeOffset.UtcNow.AddSeconds(1); // Act var updated = evt.WithReceivedAt(receivedAt); // Assert Assert.Null(evt.ReceivedAt); Assert.Equal(receivedAt, updated.ReceivedAt); Assert.Equal(evt.EventId, updated.EventId); } [Trait("Category", TestCategories.Unit)] [Fact] public void WithSequence_CreatesCopyWithSequence() { // Arrange var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash); // Act var updated = evt.WithSequence(42); // Assert Assert.Null(evt.EventSeq); Assert.Equal(42, updated.EventSeq); } [Trait("Category", TestCategories.Unit)] [Fact] public void ToJson_SerializesEvent() { // Arrange var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepCompleted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, stepId: TestStepId); // Act var json = evt.ToJson(); // Assert Assert.Contains("\"tenantId\"", json); Assert.Contains("\"eventType\"", json); Assert.Contains("pack.step.completed", json); Assert.Contains(TestStepId, json); } [Trait("Category", TestCategories.Unit)] [Fact] public void FromJson_DeserializesEvent() { // Arrange var original = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepCompleted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, stepId: TestStepId); var json = original.ToJson(); // Act var deserialized = PackRunTimelineEvent.FromJson(json); // Assert Assert.NotNull(deserialized); Assert.Equal(original.EventId, deserialized.EventId); Assert.Equal(original.TenantId, deserialized.TenantId); Assert.Equal(original.EventType, deserialized.EventType); Assert.Equal(original.RunId, deserialized.RunId); Assert.Equal(original.StepId, deserialized.StepId); } [Trait("Category", TestCategories.Unit)] [Fact] public void GenerateIdempotencyKey_ReturnsConsistentKey() { // Arrange var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash); // Act var key1 = evt.GenerateIdempotencyKey(); var key2 = evt.GenerateIdempotencyKey(); // Assert Assert.Equal(key1, key2); Assert.Contains(TestTenantId, key1); Assert.Contains(PackRunEventTypes.PackStarted, key1); } #endregion #region Event Types Tests [Trait("Category", TestCategories.Unit)] [Fact] public void PackRunEventTypes_HasExpectedValues() { Assert.Equal("pack.started", PackRunEventTypes.PackStarted); Assert.Equal("pack.completed", PackRunEventTypes.PackCompleted); Assert.Equal("pack.failed", PackRunEventTypes.PackFailed); Assert.Equal("pack.step.started", PackRunEventTypes.StepStarted); Assert.Equal("pack.step.completed", PackRunEventTypes.StepCompleted); Assert.Equal("pack.step.failed", PackRunEventTypes.StepFailed); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("pack.started", true)] [InlineData("pack.step.completed", true)] [InlineData("scan.completed", false)] [InlineData("job.started", false)] public void IsPackRunEvent_ReturnsCorrectly(string eventType, bool expected) { Assert.Equal(expected, PackRunEventTypes.IsPackRunEvent(eventType)); } #endregion #region Evidence Pointer Tests [Trait("Category", TestCategories.Unit)] [Fact] public void EvidencePointer_Bundle_CreatesCorrectType() { var bundleId = Guid.NewGuid(); var pointer = PackRunEvidencePointer.Bundle(bundleId, "sha256:abc"); Assert.Equal(PackRunEvidencePointerType.Bundle, pointer.Type); Assert.Equal(bundleId, pointer.BundleId); Assert.Equal("sha256:abc", pointer.BundleDigest); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvidencePointer_Attestation_CreatesCorrectType() { var pointer = PackRunEvidencePointer.Attestation("subject:uri", "sha256:abc"); Assert.Equal(PackRunEvidencePointerType.Attestation, pointer.Type); Assert.Equal("subject:uri", pointer.AttestationSubject); Assert.Equal("sha256:abc", pointer.AttestationDigest); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvidencePointer_Manifest_CreatesCorrectType() { var pointer = PackRunEvidencePointer.Manifest("https://example.com/manifest", "/locker/path"); Assert.Equal(PackRunEvidencePointerType.Manifest, pointer.Type); Assert.Equal("https://example.com/manifest", pointer.ManifestUri); Assert.Equal("/locker/path", pointer.LockerPath); } #endregion #region In-Memory Sink Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_WriteAsync_StoresEvent() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash); // Act var result = await sink.WriteAsync(evt, CancellationToken.None); // Assert Assert.True(result.Success); Assert.NotNull(result.Sequence); Assert.False(result.Deduplicated); Assert.Single(sink); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_WriteAsync_Deduplicates() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var evt = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "taskrunner-worker", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash); var ct = CancellationToken.None; // Act await sink.WriteAsync(evt, ct); var result = await sink.WriteAsync(evt, ct); // Assert Assert.True(result.Success); Assert.True(result.Deduplicated); Assert.Single(sink); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_AssignsMonotonicSequence() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var ct = CancellationToken.None; // Act var evt1 = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: "run-1", planHash: TestPlanHash); var evt2 = PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: "run-1", planHash: TestPlanHash); var result1 = await sink.WriteAsync(evt1, ct); var result2 = await sink.WriteAsync(evt2, ct); // Assert Assert.Equal(1, result1.Sequence); Assert.Equal(2, result2.Sequence); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_WriteBatchAsync_StoresMultiple() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var events = Enumerable.Range(0, 3).Select(i => PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.StepStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash, stepId: $"step-{i}")).ToList(); // Act var result = await sink.WriteBatchAsync(events, CancellationToken.None); // Assert Assert.Equal(3, result.Written); Assert.Equal(0, result.Deduplicated); Assert.Equal(3, sink.Count); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_GetEventsForRun_FiltersCorrectly() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var ct = CancellationToken.None; await sink.WriteAsync(PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: "run-1", planHash: TestPlanHash), ct); await sink.WriteAsync(PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: "run-2", planHash: TestPlanHash), ct); // Act var run1Events = sink.GetEventsForRun("run-1"); var run2Events = sink.GetEventsForRun("run-2"); // Assert Assert.Single(run1Events); Assert.Single(run2Events); Assert.Equal("run-1", run1Events[0].RunId); Assert.Equal("run-2", run2Events[0].RunId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task InMemorySink_Clear_RemovesAll() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); await sink.WriteAsync(PackRunTimelineEvent.Create( tenantId: TestTenantId, eventType: PackRunEventTypes.PackStarted, source: "test", occurredAt: DateTimeOffset.UtcNow, runId: TestRunId, planHash: TestPlanHash), CancellationToken.None); // Act sink.Clear(); // Assert Assert.Empty(sink); } #endregion #region Emitter Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitPackStartedAsync_CreatesEvent() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitPackStartedAsync( TestTenantId, TestRunId, TestPlanHash, projectId: TestProjectId, cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.False(result.Deduplicated); Assert.Equal(PackRunEventTypes.PackStarted, result.Event.EventType); Assert.Equal(TestRunId, result.Event.RunId); Assert.Single(sink); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitPackCompletedAsync_CreatesEvent() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitPackCompletedAsync( TestTenantId, TestRunId, TestPlanHash, cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.Equal(PackRunEventTypes.PackCompleted, result.Event.EventType); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitPackFailedAsync_CreatesEventWithError() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitPackFailedAsync( TestTenantId, TestRunId, TestPlanHash, failureReason: "Step step-001 failed", cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.Equal(PackRunEventTypes.PackFailed, result.Event.EventType); Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity); Assert.Contains("failureReason", result.Event.Attributes!.Keys); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitStepStartedAsync_IncludesAttempt() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitStepStartedAsync( TestTenantId, TestRunId, TestPlanHash, TestStepId, attempt: 2, cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.Equal(PackRunEventTypes.StepStarted, result.Event.EventType); Assert.Equal(TestStepId, result.Event.StepId); Assert.Equal("2", result.Event.Attributes!["attempt"]); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitStepCompletedAsync_IncludesDuration() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitStepCompletedAsync( TestTenantId, TestRunId, TestPlanHash, TestStepId, attempt: 1, durationMs: 123.45, cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.Equal(PackRunEventTypes.StepCompleted, result.Event.EventType); Assert.Contains("durationMs", result.Event.Attributes!.Keys); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitStepFailedAsync_IncludesError() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); // Act var result = await emitter.EmitStepFailedAsync( TestTenantId, TestRunId, TestPlanHash, TestStepId, attempt: 3, error: "Connection timeout", cancellationToken: CancellationToken.None); // Assert Assert.True(result.Success); Assert.Equal(PackRunEventTypes.StepFailed, result.Event.EventType); Assert.Equal(PackRunEventSeverity.Error, result.Event.Severity); Assert.Equal("Connection timeout", result.Event.Attributes!["error"]); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitBatchAsync_OrdersEventsDeterministically() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); var now = DateTimeOffset.UtcNow; var events = new[] { PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepStarted, "test", now.AddSeconds(2), TestRunId, TestPlanHash), PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.PackStarted, "test", now, TestRunId, TestPlanHash), PackRunTimelineEvent.Create(TestTenantId, PackRunEventTypes.StepCompleted, "test", now.AddSeconds(1), TestRunId, TestPlanHash), }; // Act var result = await emitter.EmitBatchAsync(events, CancellationToken.None); // Assert Assert.Equal(3, result.Emitted); Assert.Equal(0, result.Deduplicated); var stored = sink.GetEvents(); Assert.Equal(PackRunEventTypes.PackStarted, stored[0].EventType); Assert.Equal(PackRunEventTypes.StepCompleted, stored[1].EventType); Assert.Equal(PackRunEventTypes.StepStarted, stored[2].EventType); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Emitter_EmitBatchAsync_HandlesDuplicates() { // Arrange var sink = new InMemoryPackRunTimelineEventSink(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var emitter = new PackRunTimelineEventEmitter( sink, timeProvider, NullLogger.Instance); var ct = CancellationToken.None; var evt = PackRunTimelineEvent.Create( TestTenantId, PackRunEventTypes.PackStarted, "test", DateTimeOffset.UtcNow, TestRunId, TestPlanHash); // Emit once directly await sink.WriteAsync(evt, ct); // Act - emit batch with same event var result = await emitter.EmitBatchAsync([evt], ct); // Assert Assert.Equal(0, result.Emitted); Assert.Equal(1, result.Deduplicated); Assert.Single(sink); // Only one event stored } #endregion #region Null Sink Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task NullSink_WriteAsync_ReturnsSuccess() { // Arrange var sink = NullPackRunTimelineEventSink.Instance; var evt = PackRunTimelineEvent.Create( TestTenantId, PackRunEventTypes.PackStarted, "test", DateTimeOffset.UtcNow, TestRunId, TestPlanHash); // Act var result = await sink.WriteAsync(evt, CancellationToken.None); // Assert Assert.True(result.Success); Assert.False(result.Deduplicated); Assert.Null(result.Sequence); } #endregion } /// /// Fake time provider for testing. /// internal sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _utcNow; public FakeTimeProvider(DateTimeOffset utcNow) { _utcNow = utcNow; } public override DateTimeOffset GetUtcNow() => _utcNow; public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); }