using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Graph.Indexer.Ingestion.Sbom; using Xunit; namespace StellaOps.Graph.Indexer.Tests; public sealed class SbomIngestProcessorTests { [Fact] public async Task ProcessAsync_writes_batch_and_records_success_metrics() { var snapshot = CreateSnapshot(); var transformer = new SbomIngestTransformer(); var writer = new CaptureWriter(); var metrics = new CaptureMetrics(); var snapshotExporter = new CaptureSnapshotExporter(); var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger.Instance); await processor.ProcessAsync(snapshot, CancellationToken.None); writer.LastBatch.Should().NotBeNull(); metrics.LastRecord.Should().NotBeNull(); metrics.LastRecord!.Success.Should().BeTrue(); metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length); metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length); snapshotExporter.LastSnapshot.Should().BeSameAs(snapshot); snapshotExporter.LastBatch.Should().BeSameAs(writer.LastBatch); } [Fact] public async Task ProcessAsync_records_failure_when_writer_throws() { var snapshot = CreateSnapshot(); var transformer = new SbomIngestTransformer(); var writer = new CaptureWriter(shouldThrow: true); var metrics = new CaptureMetrics(); var snapshotExporter = new CaptureSnapshotExporter(); var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger.Instance); var act = () => processor.ProcessAsync(snapshot, CancellationToken.None); await act.Should().ThrowAsync(); metrics.LastRecord.Should().NotBeNull(); metrics.LastRecord!.Success.Should().BeFalse(); snapshotExporter.LastSnapshot.Should().BeNull(); snapshotExporter.LastBatch.Should().BeNull(); } private static SbomSnapshot CreateSnapshot() { return new SbomSnapshot { Tenant = "tenant-alpha", Source = "scanner.sbom.v1", ArtifactDigest = "sha256:test-artifact", SbomDigest = "sha256:test-sbom", CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z"), EventOffset = 1000, Artifact = new SbomArtifactMetadata { DisplayName = "registry.example.com/app:latest", Environment = "prod", Labels = new[] { "demo" }, OriginRegistry = "registry.example.com", SupplyChainStage = "deploy" }, Build = new SbomBuildMetadata { BuilderId = "builder://tekton/default", BuildType = "https://slsa.dev/provenance/v1", AttestationDigest = "sha256:attestation", Source = "scanner.build.v1", CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:05Z"), EventOffset = 2000 }, Components = new[] { new SbomComponent { Purl = "pkg:nuget/Example.Primary@1.0.0", Version = "1.0.0", Ecosystem = "nuget", Scope = "runtime", License = new SbomLicense { Spdx = "MIT", Name = "MIT License", Classification = "permissive", SourceDigest = "sha256:license001" }, Usage = "direct", DetectedBy = "sbom.analyzer.transformer", LayerDigest = "sha256:layer", EvidenceDigest = "sha256:evidence", CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:01Z"), EventOffset = 1201, Source = "scanner.component.v1", Files = new[] { new SbomComponentFile { Path = "/src/app/Program.cs", ContentSha256 = "sha256:file", LanguageHint = "csharp", SizeBytes = 1024, Scope = "build", DetectedBy = "sbom.analyzer.transformer", EvidenceDigest = "sha256:file-evidence", CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:02Z"), EventOffset = 1202, Source = "scanner.layer.v1" } }, Dependencies = Array.Empty(), SourceType = "inventory" } }, BaseArtifacts = new[] { new SbomBaseArtifact { ArtifactDigest = "sha256:base", SbomDigest = "sha256:base-sbom", DisplayName = "registry.example.com/base:2025.09", Environment = "prod", Labels = new[] { "base-image" }, OriginRegistry = "registry.example.com", SupplyChainStage = "build", CollectedAt = DateTimeOffset.Parse("2025-10-22T08:00:00Z"), EventOffset = 800, Source = "scanner.sbom.v1" } } }; } private sealed class CaptureWriter : IGraphDocumentWriter { private readonly bool _shouldThrow; public CaptureWriter(bool shouldThrow = false) { _shouldThrow = shouldThrow; } public GraphBuildBatch? LastBatch { get; private set; } public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken) { LastBatch = batch; if (_shouldThrow) { throw new InvalidOperationException("Simulated persistence failure"); } return Task.CompletedTask; } } private sealed class CaptureMetrics : ISbomIngestMetrics { public MetricRecord? LastRecord { get; private set; } public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success) { LastRecord = new MetricRecord(source, tenant, nodeCount, edgeCount, duration, success); } } private sealed class CaptureSnapshotExporter : ISbomSnapshotExporter { public SbomSnapshot? LastSnapshot { get; private set; } public GraphBuildBatch? LastBatch { get; private set; } public Task ExportAsync(SbomSnapshot snapshot, GraphBuildBatch batch, CancellationToken cancellationToken) { LastSnapshot = snapshot; LastBatch = batch; return Task.CompletedTask; } } private sealed record MetricRecord( string Source, string Tenant, int NodeCount, int EdgeCount, TimeSpan Duration, bool Success); }