using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.Storage.Mongo.Repositories; using StellaOps.Scheduler.Worker.Graph; using StellaOps.Scheduler.Worker.Graph.Cartographer; using StellaOps.Scheduler.Worker.Graph.Scheduler; using StellaOps.Scheduler.Worker.Options; using StellaOps.Scheduler.Worker.Observability; using Xunit; namespace StellaOps.Scheduler.Worker.Tests; public sealed class GraphOverlayExecutionServiceTests { [Fact] public async Task ExecuteAsync_Skips_WhenGraphDisabled() { var repository = new RecordingGraphJobRepository(); var cartographer = new StubCartographerOverlayClient(); var completion = new RecordingCompletionClient(); using var metrics = new SchedulerWorkerMetrics(); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions { Graph = new SchedulerWorkerOptions.GraphOptions { Enabled = false } }); var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger.Instance); var job = CreateOverlayJob(); var result = await service.ExecuteAsync(job, CancellationToken.None); Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type); Assert.Equal("graph_processing_disabled", result.Reason); Assert.Empty(completion.Notifications); Assert.Equal(0, cartographer.CallCount); } [Fact] public async Task ExecuteAsync_CompletesJob_OnSuccess() { var repository = new RecordingGraphJobRepository(); var cartographer = new StubCartographerOverlayClient { Result = new CartographerOverlayResult( GraphJobStatus.Completed, GraphSnapshotId: "graph_snap_2", ResultUri: "oras://graph/overlay", Error: null) }; var completion = new RecordingCompletionClient(); using var metrics = new SchedulerWorkerMetrics(); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions { Graph = new SchedulerWorkerOptions.GraphOptions { Enabled = true, MaxAttempts = 2, RetryBackoff = TimeSpan.FromMilliseconds(5) } }); var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger.Instance); var job = CreateOverlayJob(); var result = await service.ExecuteAsync(job, CancellationToken.None); Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type); Assert.Single(completion.Notifications); var notification = completion.Notifications[0]; Assert.Equal("Overlay", notification.JobType); Assert.Equal(GraphJobStatus.Completed, notification.Status); Assert.Equal("oras://graph/overlay", notification.ResultUri); Assert.Equal("graph_snap_2", notification.GraphSnapshotId); } [Fact] public async Task ExecuteAsync_Fails_AfterRetries() { var repository = new RecordingGraphJobRepository(); var cartographer = new StubCartographerOverlayClient { ExceptionToThrow = new InvalidOperationException("overlay failed") }; var completion = new RecordingCompletionClient(); using var metrics = new SchedulerWorkerMetrics(); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions { Graph = new SchedulerWorkerOptions.GraphOptions { Enabled = true, MaxAttempts = 2, RetryBackoff = TimeSpan.FromMilliseconds(1) } }); var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger.Instance); var job = CreateOverlayJob(); var result = await service.ExecuteAsync(job, CancellationToken.None); Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type); Assert.Single(completion.Notifications); Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status); Assert.Equal("overlay failed", completion.Notifications[0].Error); } [Fact] public async Task ExecuteAsync_Skips_WhenConcurrencyConflict() { var repository = new RecordingGraphJobRepository { ShouldReplaceSucceed = false }; var cartographer = new StubCartographerOverlayClient(); var completion = new RecordingCompletionClient(); using var metrics = new SchedulerWorkerMetrics(); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions { Graph = new SchedulerWorkerOptions.GraphOptions { Enabled = true } }); var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger.Instance); var job = CreateOverlayJob(); var result = await service.ExecuteAsync(job, CancellationToken.None); Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type); Assert.Equal("concurrency_conflict", result.Reason); Assert.Empty(completion.Notifications); Assert.Equal(0, cartographer.CallCount); } private static GraphOverlayJob CreateOverlayJob() => new( id: "goj_1", tenantId: "tenant-alpha", graphSnapshotId: "snap-1", overlayKind: GraphOverlayKind.Policy, overlayKey: "policy@1", status: GraphJobStatus.Pending, trigger: GraphOverlayJobTrigger.Policy, createdAt: DateTimeOffset.UtcNow, subjects: Array.Empty(), attempts: 0, metadata: Array.Empty>()); private sealed class RecordingGraphJobRepository : IGraphJobRepository { public bool ShouldReplaceSucceed { get; set; } = true; public int RunningReplacements { get; private set; } public Task TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default) { if (!ShouldReplaceSucceed) { return Task.FromResult(false); } RunningReplacements++; return Task.FromResult(true); } public Task TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } private sealed class StubCartographerOverlayClient : ICartographerOverlayClient { public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null); public Exception? ExceptionToThrow { get; set; } public int CallCount { get; private set; } public Task StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken) { CallCount++; if (ExceptionToThrow is not null) { throw ExceptionToThrow; } return Task.FromResult(Result); } } private sealed class RecordingCompletionClient : IGraphJobCompletionClient { public List Notifications { get; } = new(); public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken) { Notifications.Add(request); return Task.CompletedTask; } } }