Files
git.stella-ops.org/src/Signals/__Tests/StellaOps.Signals.Tests/UnknownsDecayServiceTests.cs
master 5a480a3c2a
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Add call graph fixtures for various languages and scenarios
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET.
- Added `all-visibility-levels.json` to validate method visibility levels in .NET.
- Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application.
- Included `go-gin-api.json` for a Go Gin API application structure.
- Added `java-spring-boot.json` for the Spring PetClinic application in Java.
- Introduced `legacy-no-schema.json` for legacy application structure without schema.
- Created `node-express-api.json` for an Express.js API application structure.
2025-12-16 10:44:24 +02:00

545 lines
19 KiB
C#

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<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(_decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.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<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.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<OperationCanceledException>(() =>
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<UnknownSymbolDocument>();
// 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<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> 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<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> 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<IReadOnlyList<UnknownSymbolDocument>>(
query.Skip(offset).Take(limit).ToList());
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.FirstOrDefault(x => x.Id == id));
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
public Task UpsertAsync(DeploymentRef deployment, CancellationToken cancellationToken) => Task.CompletedTask;
public Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<DeploymentSummary?> GetSummaryAsync(string purl, CancellationToken cancellationToken) =>
Task.FromResult<DeploymentSummary?>(null);
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> 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<GraphMetrics> metrics, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<IReadOnlyList<string>> GetStaleCallgraphsAsync(TimeSpan maxAge, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken) => Task.CompletedTask;
}
#endregion
}