using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using MsOptions = Microsoft.Extensions.Options; using StellaOps.Signals.Models; using StellaOps.Signals.Options; using StellaOps.Signals.Persistence; using StellaOps.Signals.Services; using Xunit; namespace StellaOps.Signals.Tests; public class UnknownsDecayServiceTests { private readonly MockTimeProvider _timeProvider; private readonly InMemoryUnknownsRepository _unknownsRepo; private readonly InMemoryDeploymentRefsRepository _deploymentRefs; private readonly InMemoryGraphMetricsRepository _graphMetrics; private readonly UnknownsScoringOptions _scoringOptions; private readonly UnknownsDecayOptions _decayOptions; public UnknownsDecayServiceTests() { _timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero)); _unknownsRepo = new InMemoryUnknownsRepository(); _deploymentRefs = new InMemoryDeploymentRefsRepository(); _graphMetrics = new InMemoryGraphMetricsRepository(); _scoringOptions = new UnknownsScoringOptions(); _decayOptions = new UnknownsDecayOptions(); } private (UnknownsDecayService DecayService, UnknownsScoringService ScoringService) CreateServices() { var scoringService = new UnknownsScoringService( _unknownsRepo, _deploymentRefs, _graphMetrics, MsOptions.Options.Create(_scoringOptions), _timeProvider, NullLogger.Instance); var decayService = new UnknownsDecayService( _unknownsRepo, scoringService, MsOptions.Options.Create(_scoringOptions), MsOptions.Options.Create(_decayOptions), _timeProvider, NullLogger.Instance); return (decayService, scoringService); } #region ApplyDecayAsync Tests [Fact] public async Task ApplyDecayAsync_EmptySubject_ReturnsZeroCounts() { var (decayService, _) = CreateServices(); var result = await decayService.ApplyDecayAsync("empty|1.0.0", CancellationToken.None); Assert.Equal("empty|1.0.0", result.SubjectKey); Assert.Equal(0, result.ProcessedCount); Assert.Equal(0, result.HotCount); Assert.Equal(0, result.WarmCount); Assert.Equal(0, result.ColdCount); Assert.Equal(0, result.BandChanges); } [Fact] public async Task ApplyDecayAsync_SingleUnknown_UpdatesAndPersists() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); const string subjectKey = "test|1.0.0"; var unknown = new UnknownSymbolDocument { Id = "unknown-1", SubjectKey = subjectKey, LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-10), Band = UnknownsBand.Cold }; await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None); var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None); Assert.Equal(1, result.ProcessedCount); Assert.Equal(subjectKey, result.SubjectKey); // Verify the unknown was updated in the repository var updated = await _unknownsRepo.GetBySubjectAsync(subjectKey, CancellationToken.None); Assert.Single(updated); Assert.True(updated[0].UpdatedAt >= now); } [Fact] public async Task ApplyDecayAsync_BandChangesTracked() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); const string subjectKey = "test|1.0.0"; // Create unknown that will change from COLD to HOT due to high staleness and flags var unknown = new UnknownSymbolDocument { Id = "unknown-1", SubjectKey = subjectKey, LastAnalyzedAt = now.AddDays(-14), Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true, ConflictingFeeds = true, MissingVector = true }, CreatedAt = now.AddDays(-20), Band = UnknownsBand.Cold // Initially cold }; _deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100); await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None); var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None); // Band should have changed from COLD to HOT if (result.HotCount > 0) { Assert.Equal(1, result.BandChanges); } } [Fact] public async Task ApplyDecayAsync_MultipleUnknowns_ProcessesAll() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); const string subjectKey = "test|1.0.0"; var unknowns = new[] { new UnknownSymbolDocument { Id = "unknown-1", SubjectKey = subjectKey, LastAnalyzedAt = now, Flags = new UnknownFlags(), CreatedAt = now.AddDays(-1), Band = UnknownsBand.Cold }, new UnknownSymbolDocument { Id = "unknown-2", SubjectKey = subjectKey, LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags { NoProvenanceAnchor = true }, CreatedAt = now.AddDays(-10), Band = UnknownsBand.Warm }, new UnknownSymbolDocument { Id = "unknown-3", SubjectKey = subjectKey, LastAnalyzedAt = now.AddDays(-14), Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true }, CreatedAt = now.AddDays(-20), Band = UnknownsBand.Hot } }; await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None); var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None); Assert.Equal(3, result.ProcessedCount); Assert.Equal(result.HotCount + result.WarmCount + result.ColdCount, result.ProcessedCount); } #endregion #region RunNightlyDecayBatchAsync Tests [Fact] public async Task RunNightlyDecayBatchAsync_ProcessesAllSubjects() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); // Create unknowns in multiple subjects await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[] { new UnknownSymbolDocument { Id = "u1", SubjectKey = "subject-1|1.0.0", LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-10) } }, CancellationToken.None); await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[] { new UnknownSymbolDocument { Id = "u2", SubjectKey = "subject-2|1.0.0", LastAnalyzedAt = now.AddDays(-3), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-5) } }, CancellationToken.None); var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None); Assert.Equal(2, result.TotalSubjects); Assert.Equal(2, result.TotalUnknowns); Assert.True(result.Duration >= TimeSpan.Zero); } [Fact] public async Task RunNightlyDecayBatchAsync_RespectsMaxSubjectsLimit() { var decayOptions = new UnknownsDecayOptions { MaxSubjectsPerBatch = 1 }; var scoringService = new UnknownsScoringService( _unknownsRepo, _deploymentRefs, _graphMetrics, MsOptions.Options.Create(_scoringOptions), _timeProvider, NullLogger.Instance); var decayService = new UnknownsDecayService( _unknownsRepo, scoringService, MsOptions.Options.Create(_scoringOptions), MsOptions.Options.Create(decayOptions), _timeProvider, NullLogger.Instance); var now = _timeProvider.GetUtcNow(); // Create unknowns in multiple subjects await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[] { new UnknownSymbolDocument { Id = "u1", SubjectKey = "subject-1|1.0.0", LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-10) } }, CancellationToken.None); await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[] { new UnknownSymbolDocument { Id = "u2", SubjectKey = "subject-2|1.0.0", LastAnalyzedAt = now.AddDays(-3), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-5) } }, CancellationToken.None); var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None); // Should only process 1 subject due to limit Assert.Equal(1, result.TotalSubjects); Assert.Equal(1, result.TotalUnknowns); } [Fact] public async Task RunNightlyDecayBatchAsync_CancellationRespected() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); // Create unknowns in multiple subjects for (int i = 0; i < 10; i++) { await _unknownsRepo.UpsertAsync($"subject-{i}|1.0.0", new[] { new UnknownSymbolDocument { Id = $"u{i}", SubjectKey = $"subject-{i}|1.0.0", LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags(), CreatedAt = now.AddDays(-10) } }, CancellationToken.None); } using var cts = new CancellationTokenSource(); cts.Cancel(); await Assert.ThrowsAsync(() => decayService.RunNightlyDecayBatchAsync(cts.Token)); } #endregion #region ApplyDecayToUnknownAsync Tests [Fact] public async Task ApplyDecayToUnknownAsync_UpdatesScoringFields() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); var unknown = new UnknownSymbolDocument { Id = "unknown-1", SubjectKey = "test|1.0.0", Purl = "pkg:npm/test@1.0.0", LastAnalyzedAt = now.AddDays(-7), Flags = new UnknownFlags { NoProvenanceAnchor = true }, CreatedAt = now.AddDays(-10), Score = 0, Band = UnknownsBand.Cold }; _deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50); var result = await decayService.ApplyDecayToUnknownAsync(unknown, CancellationToken.None); // Verify scoring fields were updated Assert.True(result.Score > 0); Assert.True(result.PopularityScore > 0); Assert.True(result.StalenessScore > 0); Assert.True(result.UncertaintyScore > 0); Assert.NotNull(result.NextScheduledRescan); Assert.NotNull(result.NormalizationTrace); } [Fact] public async Task ApplyDecayToUnknownAsync_SetsNextRescanBasedOnBand() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); // Create unknown that will be scored as COLD var coldUnknown = new UnknownSymbolDocument { Id = "cold-unknown", SubjectKey = "test|1.0.0", LastAnalyzedAt = now, // Fresh Flags = new UnknownFlags(), CreatedAt = now.AddDays(-1) }; var result = await decayService.ApplyDecayToUnknownAsync(coldUnknown, CancellationToken.None); Assert.Equal(UnknownsBand.Cold, result.Band); Assert.Equal(now.AddDays(_scoringOptions.ColdRescanDays), result.NextScheduledRescan); } #endregion #region Decay Result Aggregation Tests [Fact] public async Task ApplyDecayAsync_ResultCountsAreAccurate() { var (decayService, _) = CreateServices(); var now = _timeProvider.GetUtcNow(); const string subjectKey = "test|1.0.0"; // Create unknowns that will end up in different bands var unknowns = new List(); // This will be COLD (fresh, no flags) unknowns.Add(new UnknownSymbolDocument { Id = "cold-1", SubjectKey = subjectKey, LastAnalyzedAt = now, Flags = new UnknownFlags(), CreatedAt = now.AddDays(-1) }); // Add more with varying staleness and flags for (int i = 0; i < 5; i++) { unknowns.Add(new UnknownSymbolDocument { Id = $"unknown-{i}", SubjectKey = subjectKey, LastAnalyzedAt = now.AddDays(-i * 2), Flags = new UnknownFlags { NoProvenanceAnchor = i > 2, VersionRange = i > 3 }, CreatedAt = now.AddDays(-i * 2 - 5) }); } await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None); var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None); Assert.Equal(6, result.ProcessedCount); Assert.Equal(6, result.HotCount + result.WarmCount + result.ColdCount); Assert.True(result.ColdCount >= 1); // At least the fresh one should be cold } #endregion #region Test Infrastructure private sealed class MockTimeProvider : TimeProvider { private DateTimeOffset _now; public MockTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan duration) => _now = _now.Add(duration); } private sealed class InMemoryUnknownsRepository : IUnknownsRepository { private readonly List _stored = new(); public Task UpsertAsync(string subjectKey, IEnumerable items, CancellationToken cancellationToken) { _stored.RemoveAll(x => x.SubjectKey == subjectKey); _stored.AddRange(items); return Task.CompletedTask; } public Task> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) { return Task.FromResult>( _stored.Where(x => x.SubjectKey == subjectKey).ToList()); } public Task CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken) { return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey)); } public Task BulkUpdateAsync(IEnumerable items, CancellationToken cancellationToken) { foreach (var item in items) { var existing = _stored.FindIndex(x => x.Id == item.Id); if (existing >= 0) _stored[existing] = item; else _stored.Add(item); } return Task.CompletedTask; } public Task> GetAllSubjectKeysAsync(CancellationToken cancellationToken) { return Task.FromResult>( _stored.Select(x => x.SubjectKey).Distinct().ToList()); } public Task> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken) { return Task.FromResult>( _stored.Where(x => x.Band == band).Take(limit).ToList()); } public Task> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken) { var query = _stored.AsEnumerable(); if (band.HasValue) query = query.Where(x => x.Band == band.Value); return Task.FromResult>( query.Skip(offset).Take(limit).ToList()); } public Task GetByIdAsync(string id, CancellationToken cancellationToken) { return Task.FromResult(_stored.FirstOrDefault(x => x.Id == id)); } } private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository { private readonly Dictionary _counts = new(); public void SetDeploymentCount(string purl, int count) => _counts[purl] = count; public Task CountDeploymentsAsync(string purl, CancellationToken cancellationToken) { return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0); } public Task> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } public Task UpsertAsync(DeploymentRef deployment, CancellationToken cancellationToken) => Task.CompletedTask; public Task BulkUpsertAsync(IEnumerable deployments, CancellationToken cancellationToken) => Task.CompletedTask; public Task GetSummaryAsync(string purl, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository { private readonly Dictionary _metrics = new(); public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics) { _metrics[$"{symbolId}:{callgraphId}"] = metrics; } public Task GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken) { _metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics); return Task.FromResult(metrics); } public Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken) => Task.CompletedTask; public Task BulkUpsertAsync(IEnumerable metrics, CancellationToken cancellationToken) => Task.CompletedTask; public Task> GetStaleCallgraphsAsync(TimeSpan maxAge, int limit, CancellationToken cancellationToken) => Task.FromResult>(Array.Empty()); public Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken) => Task.CompletedTask; } #endregion }