using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Auth.Abstractions; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.WebService.GraphJobs; using StellaOps.Scheduler.WebService.GraphJobs.Events; using StellaOps.Scheduler.WebService.Options; using StackExchange.Redis; namespace StellaOps.Scheduler.WebService.Tests; public sealed class GraphJobEventPublisherTests { [Fact] public async Task PublishAsync_LogsEvent_WhenDriverUnsupported() { var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions { GraphJobs = { Enabled = true, Driver = "memory" } }); var loggerProvider = new ListLoggerProvider(); using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider)); var publisher = new GraphJobEventPublisher(new OptionsMonitorStub(options), new ThrowingRedisConnectionFactory(), loggerFactory.CreateLogger()); var buildJob = new GraphBuildJob( id: "gbj_test", tenantId: "tenant-alpha", sbomId: "sbom", sbomVersionId: "sbom_v1", sbomDigest: "sha256:" + new string('a', 64), status: GraphJobStatus.Completed, trigger: GraphBuildJobTrigger.SbomVersion, createdAt: DateTimeOffset.UtcNow, graphSnapshotId: "graph_snap", attempts: 1, cartographerJobId: "carto", correlationId: "corr", startedAt: DateTimeOffset.UtcNow.AddSeconds(-30), completedAt: DateTimeOffset.UtcNow, error: null, metadata: Array.Empty>()); var notification = new GraphJobCompletionNotification( buildJob.TenantId, GraphJobQueryType.Build, GraphJobStatus.Completed, DateTimeOffset.UtcNow, GraphJobResponse.From(buildJob), "oras://result", "corr", null); await publisher.PublishAsync(notification, CancellationToken.None); Assert.Contains(loggerProvider.Messages, message => message.Contains("unsupported driver", StringComparison.OrdinalIgnoreCase)); var eventPayload = loggerProvider.Messages.FirstOrDefault(message => message.Contains("\"kind\":\"scheduler.graph.job.completed\"", StringComparison.Ordinal)); Assert.NotNull(eventPayload); Assert.Contains("\"tenant\":\"tenant-alpha\"", eventPayload); Assert.Contains("\"resultUri\":\"oras://result\"", eventPayload); } [Fact] public async Task PublishAsync_Suppressed_WhenDisabled() { var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions()); var loggerProvider = new ListLoggerProvider(); using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider)); var publisher = new GraphJobEventPublisher(new OptionsMonitorStub(options), new ThrowingRedisConnectionFactory(), loggerFactory.CreateLogger()); var overlayJob = new GraphOverlayJob( id: "goj_test", tenantId: "tenant-alpha", graphSnapshotId: "graph_snap", buildJobId: null, overlayKind: GraphOverlayKind.Policy, overlayKey: "policy@1", subjects: Array.Empty(), status: GraphJobStatus.Completed, trigger: GraphOverlayJobTrigger.Policy, createdAt: DateTimeOffset.UtcNow, attempts: 1, correlationId: null, startedAt: DateTimeOffset.UtcNow.AddSeconds(-10), completedAt: DateTimeOffset.UtcNow, error: null, metadata: Array.Empty>()); var notification = new GraphJobCompletionNotification( overlayJob.TenantId, GraphJobQueryType.Overlay, GraphJobStatus.Completed, DateTimeOffset.UtcNow, GraphJobResponse.From(overlayJob), null, null, null); await publisher.PublishAsync(notification, CancellationToken.None); Assert.DoesNotContain(loggerProvider.Messages, message => message.Contains(GraphJobEventKinds.GraphJobCompleted, StringComparison.Ordinal)); } private sealed class OptionsMonitorStub : IOptionsMonitor where T : class { private readonly IOptions _options; public OptionsMonitorStub(IOptions options) { _options = options; } public T CurrentValue => _options.Value; public T Get(string? name) => _options.Value; public IDisposable? OnChange(Action listener) => null; } private sealed class ThrowingRedisConnectionFactory : IRedisConnectionFactory { public Task ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken) => throw new InvalidOperationException("Redis connection should not be established in this test."); } private sealed class ListLoggerProvider : ILoggerProvider { private readonly ListLogger _logger = new(); public IList Messages => _logger.Messages; public ILogger CreateLogger(string categoryName) => _logger; public void Dispose() { } private sealed class ListLogger : ILogger { public IList Messages { get; } = new List(); public IDisposable BeginScope(TState state) where TState : notnull => NullDisposable.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } private sealed class NullDisposable : IDisposable { public static readonly NullDisposable Instance = new(); public void Dispose() { } } } } }