199 lines
6.8 KiB
C#
199 lines
6.8 KiB
C#
// -----------------------------------------------------------------------------
|
|
// GraphQueryDeterminismTests.cs
|
|
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
|
|
// Task: GRAPH-5100-005
|
|
// Description: S1 Query determinism tests (same input → same result ordering)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using MicrosoftOptions = Microsoft.Extensions.Options;
|
|
using StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Graph.Indexer.Storage.Postgres.Tests;
|
|
|
|
/// <summary>
|
|
/// S1 Storage Layer Tests: Query Determinism Tests
|
|
/// Task GRAPH-5100-005: Query determinism (same input → same result ordering)
|
|
/// </summary>
|
|
[Collection(GraphIndexerPostgresCollection.Name)]
|
|
public sealed class GraphQueryDeterminismTests : IAsyncLifetime
|
|
{
|
|
private readonly GraphIndexerPostgresFixture _fixture;
|
|
private readonly PostgresIdempotencyStore _idempotencyStore;
|
|
|
|
public GraphQueryDeterminismTests(GraphIndexerPostgresFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
|
|
var options = fixture.Fixture.CreateOptions();
|
|
options.SchemaName = fixture.SchemaName;
|
|
var dataSource = new GraphIndexerDataSource(MicrosoftOptions.Options.Create(options), NullLogger<GraphIndexerDataSource>.Instance);
|
|
_idempotencyStore = new PostgresIdempotencyStore(dataSource, NullLogger<PostgresIdempotencyStore>.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await _fixture.TruncateAllTablesAsync();
|
|
}
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
#region Result Ordering Determinism
|
|
|
|
[Fact]
|
|
public async Task MultipleIdempotencyQueries_ReturnSameOrder()
|
|
{
|
|
// Arrange - Insert multiple tokens
|
|
var tokens = Enumerable.Range(1, 100)
|
|
.Select(i => $"seq-determinism-{i:D4}")
|
|
.ToList();
|
|
|
|
foreach (var token in tokens)
|
|
{
|
|
await _idempotencyStore.MarkSeenAsync(token, CancellationToken.None);
|
|
}
|
|
|
|
// Act - Query multiple times
|
|
var results1 = new List<bool>();
|
|
var results2 = new List<bool>();
|
|
var results3 = new List<bool>();
|
|
|
|
foreach (var token in tokens)
|
|
{
|
|
results1.Add(await _idempotencyStore.HasSeenAsync(token, CancellationToken.None));
|
|
}
|
|
foreach (var token in tokens)
|
|
{
|
|
results2.Add(await _idempotencyStore.HasSeenAsync(token, CancellationToken.None));
|
|
}
|
|
foreach (var token in tokens)
|
|
{
|
|
results3.Add(await _idempotencyStore.HasSeenAsync(token, CancellationToken.None));
|
|
}
|
|
|
|
// Assert - All results should be identical
|
|
results1.Should().BeEquivalentTo(results2, "First and second query should return identical results");
|
|
results2.Should().BeEquivalentTo(results3, "Second and third query should return identical results");
|
|
results1.Should().AllBeEquivalentTo(true, "All tokens should be marked as seen");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentQueries_ProduceDeterministicResults()
|
|
{
|
|
// Arrange
|
|
var token = $"seq-concurrent-{Guid.NewGuid():N}";
|
|
await _idempotencyStore.MarkSeenAsync(token, CancellationToken.None);
|
|
|
|
// Act - Run concurrent queries
|
|
var tasks = Enumerable.Range(1, 50)
|
|
.Select(_ => _idempotencyStore.HasSeenAsync(token, CancellationToken.None))
|
|
.ToList();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
// Assert - All concurrent queries should return the same result
|
|
results.Should().AllBeEquivalentTo(true, "All concurrent queries should return identical result");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Input Stability
|
|
|
|
[Fact]
|
|
public async Task SameInput_ProducesSameHash()
|
|
{
|
|
// Arrange
|
|
var input = "determinism-test-input";
|
|
|
|
// Act
|
|
var hash1 = ComputeHash(input);
|
|
var hash2 = ComputeHash(input);
|
|
var hash3 = ComputeHash(input);
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2, "Same input should produce same hash");
|
|
hash2.Should().Be(hash3, "Hash should be stable across multiple computations");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShuffledInputs_ProduceSameCanonicalOrdering()
|
|
{
|
|
// Arrange
|
|
var originalOrder = new[] { "node-a", "node-b", "node-c", "node-d", "node-e" };
|
|
var shuffledOrder = new[] { "node-c", "node-a", "node-e", "node-b", "node-d" };
|
|
|
|
// Act - Sort both to canonical order
|
|
var canonical1 = originalOrder.OrderBy(x => x).ToList();
|
|
var canonical2 = shuffledOrder.OrderBy(x => x).ToList();
|
|
|
|
// Assert
|
|
canonical1.Should().BeEquivalentTo(canonical2, options => options.WithStrictOrdering(),
|
|
"Shuffled inputs should produce identical canonical ordering");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Timestamps_DoNotAffectOrdering()
|
|
{
|
|
// Arrange - Insert tokens at "different" times (same logical batch)
|
|
var tokens = Enumerable.Range(1, 10)
|
|
.Select(i => $"seq-timestamp-{i:D3}")
|
|
.ToList();
|
|
|
|
// Insert in reverse order
|
|
foreach (var token in tokens.AsEnumerable().Reverse())
|
|
{
|
|
await _idempotencyStore.MarkSeenAsync(token, CancellationToken.None);
|
|
}
|
|
|
|
// Act - Query in original order
|
|
var results = new List<(string Token, bool Seen)>();
|
|
foreach (var token in tokens)
|
|
{
|
|
results.Add((token, await _idempotencyStore.HasSeenAsync(token, CancellationToken.None)));
|
|
}
|
|
|
|
// Assert - All should be seen regardless of insertion order
|
|
results.Should().AllSatisfy(r => r.Seen.Should().BeTrue());
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Cross-Tenant Isolation with Determinism
|
|
|
|
[Fact]
|
|
public async Task CrossTenant_QueriesRemainIsolated()
|
|
{
|
|
// Arrange - Create tokens that could collide without tenant isolation
|
|
var baseToken = "seq-shared-token";
|
|
var tenant1Token = $"tenant1:{baseToken}";
|
|
var tenant2Token = $"tenant2:{baseToken}";
|
|
|
|
await _idempotencyStore.MarkSeenAsync(tenant1Token, CancellationToken.None);
|
|
|
|
// Act
|
|
var tenant1Seen = await _idempotencyStore.HasSeenAsync(tenant1Token, CancellationToken.None);
|
|
var tenant2Seen = await _idempotencyStore.HasSeenAsync(tenant2Token, CancellationToken.None);
|
|
|
|
// Assert
|
|
tenant1Seen.Should().BeTrue("Tenant 1 token was marked as seen");
|
|
tenant2Seen.Should().BeFalse("Tenant 2 token was not marked as seen");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helpers
|
|
|
|
private static string ComputeHash(string input)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hash = SHA256.HashData(bytes);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
#endregion
|
|
}
|