Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -0,0 +1,514 @@
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());
}
}
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>());
}
}
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);
}
}
#endregion
}