using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using StellaOps.Determinism; using StellaOps.Scanner.Sources.Configuration; using StellaOps.Scanner.Sources.Contracts; using StellaOps.Scanner.Sources.Domain; using StellaOps.Scanner.Sources.Handlers; using StellaOps.Scanner.Sources.Persistence; using StellaOps.Scanner.Sources.Triggers; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.Sources.Tests.Triggers; public sealed class SourceTriggerDispatcherTests { private static readonly FakeTimeProvider TimeProvider = new( new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); private static readonly JsonDocument MinimalConfig = JsonDocument.Parse("{}"); [Fact] [Trait("Category", TestCategories.Unit)] public async Task DispatchAsync_QueuesTargetsAndCompletesRun() { var guidProvider = new SequentialGuidProvider(); var source = CreateSource(guidProvider); source.Activate("tester", TimeProvider); var sourceRepo = new InMemorySourceRepository(); sourceRepo.Add(source); var runRepo = new InMemoryRunRepository(); var handler = new InMemoryHandler(new[] { ScanTarget.Image("registry.example.com/app:1.0.0"), ScanTarget.Image("registry.example.com/app:1.1.0") }); var queue = new InMemoryScanJobQueue(guidProvider); var dispatcher = new SourceTriggerDispatcher( sourceRepo, runRepo, new[] { handler }, queue, NullLogger.Instance, TimeProvider, guidProvider); var result = await dispatcher.DispatchAsync( source.SourceId, SbomSourceRunTrigger.Manual, "manual", TestContext.Current.CancellationToken); result.Success.Should().BeTrue(); result.JobsQueued.Should().Be(2); result.Run.ItemsDiscovered.Should().Be(2); result.Run.ItemsSucceeded.Should().Be(2); result.Run.Status.Should().Be(SbomSourceRunStatus.Succeeded); queue.Requests.Should().HaveCount(2); runRepo.Runs.Should().ContainKey(result.Run.RunId); } [Fact] [Trait("Category", TestCategories.Unit)] public async Task ProcessScheduledSourcesAsync_DispatchesDueSources() { var guidProvider = new SequentialGuidProvider(); var source = CreateSource(guidProvider); source.Activate("tester", TimeProvider); var sourceRepo = new InMemorySourceRepository { DueSources = new List { source } }; sourceRepo.Add(source); var runRepo = new InMemoryRunRepository(); var handler = new InMemoryHandler(new[] { ScanTarget.Image("registry.example.com/app:1.0.0") }); var queue = new InMemoryScanJobQueue(guidProvider); var dispatcher = new SourceTriggerDispatcher( sourceRepo, runRepo, new[] { handler }, queue, NullLogger.Instance, TimeProvider, guidProvider); var processed = await dispatcher.ProcessScheduledSourcesAsync( TestContext.Current.CancellationToken); processed.Should().Be(1); runRepo.Runs.Should().HaveCount(1); } [Fact] [Trait("Category", TestCategories.Unit)] public async Task DispatchAsync_DisabledSource_ReturnsFailedRun() { var guidProvider = new SequentialGuidProvider(); var source = CreateSource(guidProvider); source.Disable("tester", TimeProvider); var sourceRepo = new InMemorySourceRepository(); sourceRepo.Add(source); var runRepo = new InMemoryRunRepository(); var handler = new InMemoryHandler(Array.Empty()); var queue = new InMemoryScanJobQueue(guidProvider); var dispatcher = new SourceTriggerDispatcher( sourceRepo, runRepo, new[] { handler }, queue, NullLogger.Instance, TimeProvider, guidProvider); var result = await dispatcher.DispatchAsync( source.SourceId, SbomSourceRunTrigger.Manual, "manual", TestContext.Current.CancellationToken); result.Success.Should().BeFalse(); result.Error.Should().Contain("disabled"); result.Run.Status.Should().Be(SbomSourceRunStatus.Failed); } private static SbomSource CreateSource(IGuidProvider guidProvider) { return SbomSource.Create( tenantId: "tenant-1", name: "source-1", sourceType: SbomSourceType.Docker, configuration: MinimalConfig, createdBy: "tester", timeProvider: TimeProvider, guidProvider: guidProvider); } private sealed class InMemorySourceRepository : ISbomSourceRepository { public Dictionary Sources { get; } = new(); public IReadOnlyList DueSources { get; set; } = Array.Empty(); public void Add(SbomSource source) => Sources[source.SourceId] = source; public Task GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default) => Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null); public Task GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default) => Task.FromResult(Sources.TryGetValue(sourceId, out var source) ? source : null); public Task GetByNameAsync(string tenantId, string name, CancellationToken ct = default) => Task.FromResult(Sources.Values.FirstOrDefault(s => s.TenantId == tenantId && s.Name == name)); public Task> ListAsync( string tenantId, ListSourcesRequest request, CancellationToken ct = default) => throw new NotSupportedException("ListAsync is not used in these tests."); public Task> GetDueScheduledSourcesAsync( DateTimeOffset asOf, int limit = 100, CancellationToken ct = default) => Task.FromResult>(DueSources.Take(limit).ToList()); public Task CreateAsync(SbomSource source, CancellationToken ct = default) { Sources[source.SourceId] = source; return Task.CompletedTask; } public Task UpdateAsync(SbomSource source, CancellationToken ct = default) { Sources[source.SourceId] = source; return Task.CompletedTask; } public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default) { Sources.Remove(sourceId); return Task.CompletedTask; } public Task NameExistsAsync( string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default) => Task.FromResult(Sources.Values.Any(s => s.TenantId == tenantId && s.Name == name && s.SourceId != excludeSourceId)); public Task> SearchByNameAsync(string name, CancellationToken ct = default) => Task.FromResult>(Sources.Values .Where(s => s.Name.Contains(name, StringComparison.OrdinalIgnoreCase)) .ToList()); public Task> GetDueForScheduledRunAsync(CancellationToken ct = default) => Task.FromResult(DueSources); } private sealed class InMemoryRunRepository : ISbomSourceRunRepository { public Dictionary Runs { get; } = new(); public Task GetByIdAsync(string tenantId, Guid runId, CancellationToken ct = default) => Task.FromResult( Runs.TryGetValue(runId, out var run) && string.Equals(run.TenantId, tenantId, StringComparison.Ordinal) ? run : null); public Task> ListForSourceAsync( string tenantId, Guid sourceId, ListSourceRunsRequest request, CancellationToken ct = default) => throw new NotSupportedException("ListForSourceAsync is not used in these tests."); public Task CreateAsync(SbomSourceRun run, CancellationToken ct = default) { Runs[run.RunId] = run; return Task.CompletedTask; } public Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default) { Runs[run.RunId] = run; return Task.CompletedTask; } public Task> GetStaleRunsAsync( TimeSpan olderThan, int limit = 100, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); public Task GetStatsAsync(string tenantId, Guid sourceId, CancellationToken ct = default) => Task.FromResult(new SourceRunStats()); } private sealed class InMemoryScanJobQueue : IScanJobQueue { private readonly IGuidProvider _guidProvider; public List Requests { get; } = new(); public InMemoryScanJobQueue(IGuidProvider guidProvider) { _guidProvider = guidProvider; } public Task EnqueueAsync(ScanJobRequest request, CancellationToken ct = default) { Requests.Add(request); return Task.FromResult(_guidProvider.NewGuid()); } } private sealed class InMemoryHandler : ISourceTypeHandler { private readonly IReadOnlyList _targets; public InMemoryHandler(IReadOnlyList targets) { _targets = targets; } public SbomSourceType SourceType => SbomSourceType.Docker; public Task> DiscoverTargetsAsync( SbomSource source, TriggerContext context, CancellationToken ct = default) => Task.FromResult(_targets); public ConfigValidationResult ValidateConfiguration(JsonDocument configuration) => ConfigValidationResult.Success(); public Task TestConnectionAsync(SbomSource source, CancellationToken ct = default) => Task.FromResult(ConnectionTestResult.Succeeded(TimeProvider)); } }