Files
git.stella-ops.org/src/Signals/StellaOps.Signals.Storage.Postgres.Tests/CallGraphProjectionIntegrationTests.cs

190 lines
7.2 KiB
C#

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;
/// <summary>
/// Integration tests for callgraph projection to relational tables.
/// </summary>
[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<SignalsDataSource>.Instance);
var projectionRepository = new PostgresCallGraphProjectionRepository(
_dataSource,
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
_queryRepository = new PostgresCallGraphQueryRepository(
_dataSource,
NullLogger<PostgresCallGraphQueryRepository>.Instance);
_service = new CallGraphSyncService(
projectionRepository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.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<CallgraphNode>
{
new() { Id = "node-1", Name = "GetUsers", Namespace = "Api.Controllers" },
new() { Id = "node-2", Name = "CreateUser", Namespace = "Api.Controllers" }
},
Edges = new List<CallgraphEdge>(),
Entrypoints = new List<CallgraphEntrypoint>
{
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<CallgraphNode>
{
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<CallgraphEdge>
{
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<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 }
}
};
}
}