using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using MicrosoftOptions = Microsoft.Extensions.Options; using StellaOps.Signals.Models; using StellaOps.Signals.Services; using StellaOps.Signals.Storage.Postgres.Repositories; using Xunit; using Xunit.Abstractions; using StellaOps.TestKit; namespace StellaOps.Signals.Storage.Postgres.Tests; /// /// Integration tests for callgraph projection to relational tables. /// [Collection(SignalsPostgresCollection.Name)] public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime { private readonly SignalsPostgresFixture _fixture; private readonly ITestOutputHelper _output; private readonly SignalsDataSource _dataSource; private readonly PostgresCallGraphQueryRepository _queryRepository; private readonly CallGraphSyncService _service; public CallGraphProjectionIntegrationTests(SignalsPostgresFixture fixture, ITestOutputHelper output) { _fixture = fixture; _output = output; var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; _dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger.Instance); var projectionRepository = new PostgresCallGraphProjectionRepository( _dataSource, NullLogger.Instance); _queryRepository = new PostgresCallGraphQueryRepository( _dataSource, NullLogger.Instance); _service = new CallGraphSyncService( projectionRepository, TimeProvider.System, NullLogger.Instance); } public async Task InitializeAsync() { await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;"); } public async Task DisposeAsync() { await _dataSource.DisposeAsync(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SyncAsync_ProjectsNodesToRelationalTable() { var scanId = Guid.NewGuid(); var document = CreateSampleDocument(); // Act var result = await _service.SyncAsync(scanId, "sha256:test-digest", document); // Assert Assert.True(result.WasUpdated); Assert.Equal(document.Nodes.Count, result.NodesProjected); Assert.Equal(document.Edges.Count, result.EdgesProjected); Assert.Equal(document.Entrypoints.Count, result.EntrypointsProjected); Assert.True(result.DurationMs >= 0); _output.WriteLine($"Projected {result.NodesProjected} nodes, {result.EdgesProjected} edges, {result.EntrypointsProjected} entrypoints in {result.DurationMs}ms"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SyncAsync_IsIdempotent_DoesNotCreateDuplicates() { var scanId = Guid.NewGuid(); var document = CreateSampleDocument(); // Act - project twice var result1 = await _service.SyncAsync(scanId, "sha256:test-digest", document); var result2 = await _service.SyncAsync(scanId, "sha256:test-digest", document); // Assert - second run should update, not duplicate Assert.Equal(result1.NodesProjected, result2.NodesProjected); Assert.Equal(result1.EdgesProjected, result2.EdgesProjected); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SyncAsync_WithEntrypoints_ProjectsEntrypointsCorrectly() { var scanId = Guid.NewGuid(); var document = new CallgraphDocument { Id = Guid.NewGuid().ToString("N"), Language = "csharp", GraphHash = "test-hash", Nodes = new List { new() { Id = "node-1", Name = "GetUsers", Namespace = "Api.Controllers" }, new() { Id = "node-2", Name = "CreateUser", Namespace = "Api.Controllers" } }, Edges = new List(), Entrypoints = new List { new() { NodeId = "node-1", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "GET", Order = 0 }, new() { NodeId = "node-2", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "POST", Order = 1 } } }; // Act var result = await _service.SyncAsync(scanId, "sha256:test-digest", document); // Assert Assert.Equal(2, result.EntrypointsProjected); _output.WriteLine($"Projected {result.EntrypointsProjected} HTTP entrypoints"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeleteByScanAsync_RemovesAllProjectedData() { var scanId = Guid.NewGuid(); var document = CreateSampleDocument(); // Project first await _service.SyncAsync(scanId, "sha256:test-digest", document); // Act await _service.DeleteByScanAsync(scanId); // Assert - query should return empty stats var stats = await _queryRepository.GetStatsAsync(scanId); Assert.Equal(0, stats.NodeCount); Assert.Equal(0, stats.EdgeCount); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task QueryRepository_CanQueryProjectedData() { var scanId = Guid.NewGuid(); var document = CreateSampleDocument(); // Project await _service.SyncAsync(scanId, "sha256:test-digest", document); // Act var stats = await _queryRepository.GetStatsAsync(scanId); // Assert Assert.Equal(document.Nodes.Count, stats.NodeCount); Assert.Equal(document.Edges.Count, stats.EdgeCount); _output.WriteLine($"Query returned: {stats.NodeCount} nodes, {stats.EdgeCount} edges"); } private static CallgraphDocument CreateSampleDocument() { return new CallgraphDocument { Id = Guid.NewGuid().ToString("N"), Language = "csharp", GraphHash = "sha256:sample-graph-hash", Nodes = new List { new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true }, new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal }, new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private } }, Edges = new List { new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }, new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 } }, Entrypoints = new List { new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 } } }; } }