// ----------------------------------------------------------------------------- // ReachabilityCacheTests.cs // Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017) // Description: Unit tests for reachability cache components. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.Reachability.Cache; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Reachability.Tests; public sealed class GraphDeltaComputerTests { private readonly GraphDeltaComputer _computer; private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; public GraphDeltaComputerTests() { _computer = new GraphDeltaComputer(NullLogger.Instance); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty() { // Arrange var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") }); var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") }); // Act var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken); // Assert delta.HasChanges.Should().BeFalse(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta() { // Arrange var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") }); var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") }); // Act var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken); // Assert delta.HasChanges.Should().BeTrue(); delta.AddedNodes.Should().Contain("C"); delta.RemovedNodes.Should().BeEmpty(); delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C"); delta.AffectedMethodKeys.Should().Contain("C"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta() { // Arrange var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") }); var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") }); // Act var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken); // Assert delta.HasChanges.Should().BeTrue(); delta.RemovedNodes.Should().Contain("C"); delta.AddedNodes.Should().BeEmpty(); delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods() { // Arrange var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") }); var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") }); // Act var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken); // Assert delta.HasChanges.Should().BeTrue(); delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C"); delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B"); delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" }); } private sealed class TestGraphSnapshot : IGraphSnapshot { public string Hash { get; } public IReadOnlySet NodeKeys { get; } public IReadOnlyList Edges { get; } public IReadOnlySet EntryPoints { get; } public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null) { Hash = hash; NodeKeys = nodes.ToHashSet(); Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList(); EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet(); } } } public sealed class ImpactSetCalculatorTests { private readonly ImpactSetCalculator _calculator; private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; public ImpactSetCalculatorTests() { _calculator = new ImpactSetCalculator(NullLogger.Instance); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty() { // Arrange var delta = GraphDelta.Empty; var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") }); // Act var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken); // Assert impact.RequiresFullRecompute.Should().BeFalse(); impact.AffectedEntryPoints.Should().BeEmpty(); impact.SavingsRatio.Should().Be(1.0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry() { // Arrange var delta = new GraphDelta { AddedNodes = new HashSet { "C" }, AddedEdges = new List { new("B", "C") }, AffectedMethodKeys = new HashSet { "B", "C" } }; var graph = new TestGraphSnapshot( "hash2", new[] { "Entry", "Entry2", "Entry3", "Entry4", "A", "B", "C" }, new[] { ("Entry", "A"), ("A", "B"), ("B", "C") }, new[] { "Entry", "Entry2", "Entry3", "Entry4" }); // Act var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken); // Assert impact.RequiresFullRecompute.Should().BeFalse(); impact.AffectedEntryPoints.Should().Contain("Entry"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute() { // Arrange - More than 30% affected var delta = new GraphDelta { AddedNodes = new HashSet { "Entry1", "Entry2", "Entry3", "Entry4" }, AffectedMethodKeys = new HashSet { "Entry1", "Entry2", "Entry3", "Entry4" } }; var graph = new TestGraphSnapshot( "hash2", new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" }, new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") }, new[] { "Entry1", "Entry2", "Entry3", "Entry4" }); // Act var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken); // Assert - All 4 entries affected = 100% > 30% threshold impact.RequiresFullRecompute.Should().BeTrue(); } private sealed class TestGraphSnapshot : IGraphSnapshot { public string Hash { get; } public IReadOnlySet NodeKeys { get; } public IReadOnlyList Edges { get; } public IReadOnlySet EntryPoints { get; } public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null) { Hash = hash; NodeKeys = nodes.ToHashSet(); Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList(); EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet(); } } } public sealed class StateFlipDetectorTests { private readonly StateFlipDetector _detector; private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; public StateFlipDetectorTests() { _detector = new StateFlipDetector(NullLogger.Instance); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty() { // Arrange var previous = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; var current = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.HasFlips.Should().BeFalse(); result.NewRiskCount.Should().Be(0); result.MitigatedCount.Should().Be(0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk() { // Arrange var previous = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; var current = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.HasFlips.Should().BeTrue(); result.NewRiskCount.Should().Be(1); result.MitigatedCount.Should().Be(0); result.NewlyReachable.Should().ContainSingle() .Which.FlipType.Should().Be(StateFlipType.BecameReachable); result.ShouldBlockPr.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated() { // Arrange var previous = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; var current = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.HasFlips.Should().BeTrue(); result.NewRiskCount.Should().Be(0); result.MitigatedCount.Should().Be(1); result.NewlyUnreachable.Should().ContainSingle() .Which.FlipType.Should().Be(StateFlipType.BecameUnreachable); result.ShouldBlockPr.Should().BeFalse(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk() { // Arrange var previous = new List(); var current = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.HasFlips.Should().BeTrue(); result.NewRiskCount.Should().Be(1); result.ShouldBlockPr.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated() { // Arrange var previous = new List { new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; var current = new List(); // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.HasFlips.Should().BeTrue(); result.MitigatedCount.Should().Be(1); result.ShouldBlockPr.Should().BeFalse(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly() { // Arrange var previous = new List { new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }, new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; var current = new List { new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }, new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }, new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow } }; // Act var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken); // Assert result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable result.NetChange.Should().Be(1); // +2 - 1 = 1 } }