using System.Collections.Immutable; using System.Text.Json.Nodes; using StellaOps.Graph.Indexer.Documents; using StellaOps.Graph.Indexer.Ingestion.Sbom; using StellaOps.TestKit; namespace StellaOps.Graph.Indexer.Tests; public sealed class GraphSnapshotBuilderTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Build_ProducesDeterministicAdjacencyOrdering() { var snapshot = new SbomSnapshot { Tenant = "tenant-a", ArtifactDigest = "sha256:artifact", SbomDigest = "sha256:sbom", BaseArtifacts = Array.Empty() }; // Nodes intentionally provided out of order to verify deterministic sorting. var nodes = new[] { CreateComponentNode("node-b", "pkg:type/b@2.0"), CreateArtifactNode("node-artifact", snapshot.ArtifactDigest, snapshot.SbomDigest), CreateComponentNode("node-a", "pkg:type/a@1.0") }.ToImmutableArray(); // Edges also out of order; adjacency should normalize ordering. var edges = new[] { CreateEdge("edge-b", source: "node-artifact", target: "node-b"), CreateEdge("edge-a", source: "node-artifact", target: "node-a") }.ToImmutableArray(); var batch = new GraphBuildBatch(nodes, edges); var builder = new GraphSnapshotBuilder(); var result = builder.Build(snapshot, batch, generatedAt: DateTimeOffset.Parse("2025-11-18T00:00:00Z")); // Node ordering is lexicographic by node id. var nodeIds = result.Adjacency.Nodes.Select(n => n.NodeId).ToArray(); Assert.Equal(new[] { "node-a", "node-artifact", "node-b" }, nodeIds); // Outgoing edges are sorted per-node. var artifactNode = result.Adjacency.Nodes.Single(n => n.NodeId == "node-artifact"); Assert.Equal(new[] { "edge-a", "edge-b" }, artifactNode.OutgoingEdges.ToArray()); // Incoming edges preserved deterministically on targets. Assert.Equal(new[] { "edge-a" }, result.Adjacency.Nodes.Single(n => n.NodeId == "node-a").IncomingEdges.ToArray()); Assert.Equal(new[] { "edge-b" }, result.Adjacency.Nodes.Single(n => n.NodeId == "node-b").IncomingEdges.ToArray()); } [Trait("Category", TestCategories.Unit)] [Fact] public void Build_ComputesStableManifestHash_ForShuffledInputs() { var snapshot = new SbomSnapshot { Tenant = "tenant-b", ArtifactDigest = "sha256:artifact-b", SbomDigest = "sha256:sbom-b", BaseArtifacts = Array.Empty() }; var nodesA = new[] { CreateArtifactNode("art", snapshot.ArtifactDigest, snapshot.SbomDigest), CreateComponentNode("comp-1", "pkg:nuget/one@1.0.0"), CreateComponentNode("comp-2", "pkg:nuget/two@2.0.0") }.ToImmutableArray(); var edgesA = new[] { CreateEdge("e2", source: "art", target: "comp-2"), CreateEdge("e1", source: "art", target: "comp-1") }.ToImmutableArray(); var builder = new GraphSnapshotBuilder(); var t = DateTimeOffset.Parse("2025-11-18T01:23:45Z"); var baseline = builder.Build(snapshot, new GraphBuildBatch(nodesA, edgesA), t); // Shuffle nodes/edges and ensure hash remains identical. var nodesB = nodesA.Reverse().ToImmutableArray(); var edgesB = edgesA.Reverse().ToImmutableArray(); var shuffled = builder.Build(snapshot, new GraphBuildBatch(nodesB, edgesB), t); Assert.Equal(baseline.Manifest.Hash, shuffled.Manifest.Hash); Assert.Equal(baseline.Adjacency.Nodes.Select(n => n.NodeId), shuffled.Adjacency.Nodes.Select(n => n.NodeId)); } private static JsonObject CreateArtifactNode(string id, string artifactDigest, string sbomDigest) { var attributes = new JsonObject { ["artifact_digest"] = artifactDigest, ["sbom_digest"] = sbomDigest }; return new JsonObject { ["id"] = id, ["kind"] = "artifact", ["attributes"] = attributes }; } private static JsonObject CreateComponentNode(string id, string purl) { var attributes = new JsonObject { ["purl"] = purl }; return new JsonObject { ["id"] = id, ["kind"] = "component", ["attributes"] = attributes }; } private static JsonObject CreateEdge(string id, string source, string target) { return new JsonObject { ["id"] = id, ["source"] = source, ["target"] = target }; } }