using FluentAssertions; using Microsoft.Extensions.Logging; using StellaOps.Signals.Models; using StellaOps.Signals.Services; using Xunit; namespace StellaOps.Signals.Tests; public class SchedulerRescanOrchestratorTests { private readonly MockSchedulerJobClient _mockClient = new(); private readonly FakeTimeProvider _timeProvider = new(); private readonly ILogger _logger; private readonly SchedulerRescanOrchestrator _sut; public SchedulerRescanOrchestratorTests() { _logger = LoggerFactory.Create(b => b.AddDebug()).CreateLogger(); _sut = new SchedulerRescanOrchestrator(_mockClient, _timeProvider, _logger); } [Fact] public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Immediate() { // Arrange var unknown = CreateUnknown("pkg:npm/lodash@4.17.21", UnknownsBand.Hot); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert result.Success.Should().BeTrue(); _mockClient.LastRequest.Should().NotBeNull(); _mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.High); _mockClient.LastRequest.PackageUrl.Should().Be("pkg:npm/lodash@4.17.21"); } [Fact] public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Scheduled() { // Arrange var unknown = CreateUnknown("pkg:maven/com.example/lib@1.0.0", UnknownsBand.Warm); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled); // Assert result.Success.Should().BeTrue(); _mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Normal); } [Fact] public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Batch() { // Arrange var unknown = CreateUnknown("pkg:nuget/newtonsoft.json@13.0.1", UnknownsBand.Cold); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch); // Assert result.Success.Should().BeTrue(); _mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Low); } [Fact] public async Task TriggerRescanAsync_PropagatesCorrelationId() { // Arrange var unknown = CreateUnknown("pkg:pypi/requests@2.28.0", UnknownsBand.Hot); unknown.CallgraphId = "tenant123:graph456"; // Act await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert _mockClient.LastRequest!.CorrelationId.Should().Be("tenant123:graph456"); _mockClient.LastRequest.TenantId.Should().Be("tenant123"); } [Fact] public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForImmediate() { // Arrange var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero); _timeProvider.SetUtcNow(now); var unknown = CreateUnknown("pkg:npm/axios@1.0.0", UnknownsBand.Hot); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert result.NextScheduledRescan.Should().Be(now.AddMinutes(15)); } [Fact] public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForScheduled() { // Arrange var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero); _timeProvider.SetUtcNow(now); var unknown = CreateUnknown("pkg:npm/express@4.18.0", UnknownsBand.Warm); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled); // Assert result.NextScheduledRescan.Should().Be(now.AddHours(24)); } [Fact] public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForBatch() { // Arrange var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero); _timeProvider.SetUtcNow(now); var unknown = CreateUnknown("pkg:npm/mocha@10.0.0", UnknownsBand.Cold); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch); // Assert result.NextScheduledRescan.Should().Be(now.AddDays(7)); } [Fact] public async Task TriggerRescanAsync_ReturnsFailure_WhenClientFails() { // Arrange _mockClient.ShouldFail = true; _mockClient.FailureMessage = "Queue unavailable"; var unknown = CreateUnknown("pkg:npm/fail@1.0.0", UnknownsBand.Hot); // Act var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert result.Success.Should().BeFalse(); result.ErrorMessage.Should().Be("Queue unavailable"); } [Fact] public async Task TriggerBatchRescanAsync_ProcessesAllItems() { // Arrange var unknowns = new[] { CreateUnknown("pkg:npm/a@1.0.0", UnknownsBand.Hot), CreateUnknown("pkg:npm/b@1.0.0", UnknownsBand.Hot), CreateUnknown("pkg:npm/c@1.0.0", UnknownsBand.Hot) }; // Act var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate); // Assert result.TotalRequested.Should().Be(3); result.SuccessCount.Should().Be(3); result.FailureCount.Should().Be(0); result.Results.Should().HaveCount(3); } [Fact] public async Task TriggerBatchRescanAsync_EmptyList_ReturnsEmpty() { // Arrange var unknowns = Array.Empty(); // Act var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate); // Assert result.TotalRequested.Should().Be(0); result.SuccessCount.Should().Be(0); result.FailureCount.Should().Be(0); } [Fact] public async Task TriggerRescanAsync_ExtractsTenantFromCallgraphId() { // Arrange var unknown = CreateUnknown("pkg:npm/test@1.0.0", UnknownsBand.Hot); unknown.CallgraphId = "acme-corp:cg-12345"; // Act await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert _mockClient.LastRequest!.TenantId.Should().Be("acme-corp"); } [Fact] public async Task TriggerRescanAsync_UsesDefaultTenant_WhenNoCallgraphId() { // Arrange var unknown = CreateUnknown("pkg:npm/orphan@1.0.0", UnknownsBand.Hot); unknown.CallgraphId = null; // Act await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate); // Assert _mockClient.LastRequest!.TenantId.Should().Be("default"); } private static UnknownSymbolDocument CreateUnknown(string purl, UnknownsBand band) { return new UnknownSymbolDocument { Id = Guid.NewGuid().ToString("N"), SubjectKey = purl, Purl = purl, Band = band, Score = band switch { UnknownsBand.Hot => 0.85, UnknownsBand.Warm => 0.55, UnknownsBand.Cold => 0.35, _ => 0.15 } }; } private sealed class MockSchedulerJobClient : ISchedulerJobClient { public RescanJobRequest? LastRequest { get; private set; } public bool ShouldFail { get; set; } public string FailureMessage { get; set; } = "Mock failure"; public Task CreateRescanJobAsync( RescanJobRequest request, CancellationToken cancellationToken = default) { LastRequest = request; if (ShouldFail) { return Task.FromResult(SchedulerJobResult.Failed(FailureMessage)); } var jobId = $"mock-job-{Guid.NewGuid():N}"; var runId = $"mock-run-{DateTime.UtcNow:yyyyMMddHHmmss}"; return Task.FromResult(SchedulerJobResult.Succeeded(jobId, runId)); } public async Task CreateRescanJobsAsync( IReadOnlyList requests, CancellationToken cancellationToken = default) { var results = new List(); foreach (var request in requests) { var result = await CreateRescanJobAsync(request, cancellationToken); results.Add(result); } return new BatchSchedulerJobResult( requests.Count, results.Count(r => r.Success), results.Count(r => !r.Success), results); } } private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _utcNow = DateTimeOffset.UtcNow; public void SetUtcNow(DateTimeOffset value) => _utcNow = value; public override DateTimeOffset GetUtcNow() => _utcNow; } }