using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.WebService.GraphJobs; using Xunit; namespace StellaOps.Scheduler.WebService.Tests; public sealed class GraphJobServiceTests { private static readonly DateTimeOffset FixedTime = new(2025, 11, 4, 12, 0, 0, TimeSpan.Zero); [Fact] public async Task CompleteBuildJob_PersistsMetadataAndPublishesOnce() { var store = new TrackingGraphJobStore(); var initial = CreateBuildJob(); await store.AddAsync(initial, CancellationToken.None); var clock = new FixedClock(FixedTime); var publisher = new RecordingPublisher(); var webhook = new RecordingWebhookClient(); var service = new GraphJobService(store, clock, publisher, webhook); var request = new GraphJobCompletionRequest { JobId = initial.Id, JobType = GraphJobQueryType.Build, Status = GraphJobStatus.Completed, OccurredAt = FixedTime, GraphSnapshotId = "graph_snap_final ", ResultUri = "oras://cartographer/bundle ", CorrelationId = "corr-123 " }; var response = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); Assert.Equal(GraphJobStatus.Completed, response.Status); Assert.Equal(1, store.BuildUpdateCount); Assert.Single(publisher.Notifications); Assert.Single(webhook.Notifications); var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); Assert.NotNull(stored); Assert.Equal("graph_snap_final", stored!.GraphSnapshotId); Assert.Equal("corr-123", stored.CorrelationId); Assert.True(stored.Metadata.TryGetValue("resultUri", out var resultUri)); Assert.Equal("oras://cartographer/bundle", resultUri); } [Fact] public async Task CompleteBuildJob_IsIdempotentWhenAlreadyCompleted() { var store = new TrackingGraphJobStore(); var initial = CreateBuildJob(); await store.AddAsync(initial, CancellationToken.None); var clock = new FixedClock(FixedTime); var publisher = new RecordingPublisher(); var webhook = new RecordingWebhookClient(); var service = new GraphJobService(store, clock, publisher, webhook); var request = new GraphJobCompletionRequest { JobId = initial.Id, JobType = GraphJobQueryType.Build, Status = GraphJobStatus.Completed, OccurredAt = FixedTime, GraphSnapshotId = "graph_snap_final", ResultUri = "oras://cartographer/bundle", CorrelationId = "corr-123" }; await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); var updateCountAfterFirst = store.BuildUpdateCount; var secondResponse = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None); Assert.Equal(GraphJobStatus.Completed, secondResponse.Status); Assert.Equal(updateCountAfterFirst, store.BuildUpdateCount); Assert.Single(publisher.Notifications); Assert.Single(webhook.Notifications); } [Fact] public async Task CompleteBuildJob_UpdatesResultUriWithoutReemittingEvent() { var store = new TrackingGraphJobStore(); var initial = CreateBuildJob(); await store.AddAsync(initial, CancellationToken.None); var clock = new FixedClock(FixedTime); var publisher = new RecordingPublisher(); var webhook = new RecordingWebhookClient(); var service = new GraphJobService(store, clock, publisher, webhook); var firstRequest = new GraphJobCompletionRequest { JobId = initial.Id, JobType = GraphJobQueryType.Build, Status = GraphJobStatus.Completed, OccurredAt = FixedTime, GraphSnapshotId = "graph_snap_final", ResultUri = null, CorrelationId = "corr-123" }; await service.CompleteJobAsync(initial.TenantId, firstRequest, CancellationToken.None); Assert.Equal(1, store.BuildUpdateCount); Assert.Single(publisher.Notifications); Assert.Single(webhook.Notifications); var secondRequest = firstRequest with { ResultUri = "oras://cartographer/bundle-v2", OccurredAt = FixedTime.AddSeconds(30) }; var response = await service.CompleteJobAsync(initial.TenantId, secondRequest, CancellationToken.None); Assert.Equal(GraphJobStatus.Completed, response.Status); Assert.Equal(2, store.BuildUpdateCount); Assert.Single(publisher.Notifications); Assert.Single(webhook.Notifications); var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None); Assert.NotNull(stored); Assert.True(stored!.Metadata.TryGetValue("resultUri", out var resultUri)); Assert.Equal("oras://cartographer/bundle-v2", resultUri); } [Fact] public async Task CreateBuildJob_NormalizesSbomDigest() { var store = new TrackingGraphJobStore(); var clock = new FixedClock(FixedTime); var publisher = new RecordingPublisher(); var webhook = new RecordingWebhookClient(); var service = new GraphJobService(store, clock, publisher, webhook); var request = new GraphBuildJobRequest { SbomId = "sbom-alpha", SbomVersionId = "sbom-alpha-v1", SbomDigest = " SHA256:" + new string('A', 64) + " ", }; var created = await service.CreateBuildJobAsync("tenant-alpha", request, CancellationToken.None); Assert.Equal("sha256:" + new string('a', 64), created.SbomDigest); } [Fact] public async Task CreateBuildJob_RejectsDigestWithoutPrefix() { var store = new TrackingGraphJobStore(); var clock = new FixedClock(FixedTime); var publisher = new RecordingPublisher(); var webhook = new RecordingWebhookClient(); var service = new GraphJobService(store, clock, publisher, webhook); var request = new GraphBuildJobRequest { SbomId = "sbom-alpha", SbomVersionId = "sbom-alpha-v1", SbomDigest = new string('a', 64), }; var ex = await Assert.ThrowsAsync( async () => await service.CreateBuildJobAsync("tenant-alpha", request, CancellationToken.None)); Assert.Contains("sha256:", ex.Message, StringComparison.Ordinal); } private static GraphBuildJob CreateBuildJob() { var digest = "sha256:" + new string('a', 64); return new GraphBuildJob( id: "gbj_test", tenantId: "tenant-alpha", sbomId: "sbom-alpha", sbomVersionId: "sbom-alpha-v1", sbomDigest: digest, status: GraphJobStatus.Pending, trigger: GraphBuildJobTrigger.SbomVersion, createdAt: FixedTime, metadata: null); } private sealed class TrackingGraphJobStore : IGraphJobStore { private readonly InMemoryGraphJobStore _inner = new(); public int BuildUpdateCount { get; private set; } public int OverlayUpdateCount { get; private set; } public ValueTask AddAsync(GraphBuildJob job, CancellationToken cancellationToken) => _inner.AddAsync(job, cancellationToken); public ValueTask AddAsync(GraphOverlayJob job, CancellationToken cancellationToken) => _inner.AddAsync(job, cancellationToken); public ValueTask GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken) => _inner.GetJobsAsync(tenantId, query, cancellationToken); public ValueTask GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) => _inner.GetBuildJobAsync(tenantId, jobId, cancellationToken); public ValueTask GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken) => _inner.GetOverlayJobAsync(tenantId, jobId, cancellationToken); public async ValueTask> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) { BuildUpdateCount++; return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); } public async ValueTask> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken) { OverlayUpdateCount++; return await _inner.UpdateAsync(job, expectedStatus, cancellationToken); } public ValueTask> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken) => _inner.GetOverlayJobsAsync(tenantId, cancellationToken); } private sealed class RecordingPublisher : IGraphJobCompletionPublisher { public List Notifications { get; } = new(); public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) { Notifications.Add(notification); return Task.CompletedTask; } } private sealed class RecordingWebhookClient : ICartographerWebhookClient { public List Notifications { get; } = new(); public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) { Notifications.Add(notification); return Task.CompletedTask; } } private sealed class FixedClock : ISystemClock { public FixedClock(DateTimeOffset utcNow) { UtcNow = utcNow; } public DateTimeOffset UtcNow { get; set; } } }