Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -0,0 +1,221 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for Valkey/Redis cache tests.
/// Inherit from this class to verify cache operations work correctly.
/// </summary>
/// <typeparam name="TEntity">The entity type being cached.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class CacheIdempotencyTests<TEntity, TKey> : IClassFixture<ValkeyFixture>
where TEntity : class
where TKey : notnull
{
protected readonly ValkeyFixture Fixture;
protected CacheIdempotencyTests(ValkeyFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = ValkeyIsolationMode.DatabasePerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key);
/// <summary>
/// Converts a key to its Redis key string.
/// </summary>
protected abstract string ToRedisKey(TKey key);
/// <summary>
/// Sets the entity in cache.
/// </summary>
protected abstract Task SetAsync(ValkeyTestSession session, TKey key, TEntity entity, TimeSpan? expiry = null, CancellationToken ct = default);
/// <summary>
/// Gets the entity from cache.
/// </summary>
protected abstract Task<TEntity?> GetAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Deletes the entity from cache.
/// </summary>
protected abstract Task<bool> DeleteAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Checks if key exists in cache.
/// </summary>
protected abstract Task<bool> ExistsAsync(ValkeyTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Serializes entity to a deterministic string representation.
/// </summary>
protected abstract string SerializeEntity(TEntity entity);
[Fact]
public async Task Set_Same_Key_Multiple_Times_Is_Idempotent()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Set_Same_Key_Multiple_Times_Is_Idempotent));
var key = GenerateKey(1);
var entity = CreateTestEntity(key);
// Act
await SetAsync(session, key, entity);
await SetAsync(session, key, entity);
await SetAsync(session, key, entity);
// Assert
var result = await GetAsync(session, key);
result.Should().NotBeNull();
SerializeEntity(result!).Should().Be(SerializeEntity(entity));
}
[Fact]
public async Task Get_NonExistent_Key_Returns_Null()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Get_NonExistent_Key_Returns_Null));
var key = GenerateKey(999);
// Act
var result = await GetAsync(session, key);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task Delete_Removes_Key()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Delete_Removes_Key));
var key = GenerateKey(2);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var deleted = await DeleteAsync(session, key);
// Assert
deleted.Should().BeTrue();
var exists = await ExistsAsync(session, key);
exists.Should().BeFalse();
}
[Fact]
public async Task Delete_NonExistent_Key_Returns_False()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Delete_NonExistent_Key_Returns_False));
var key = GenerateKey(888);
// Act
var deleted = await DeleteAsync(session, key);
// Assert
deleted.Should().BeFalse();
}
[Fact]
public async Task Set_With_Expiry_Key_Expires()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Set_With_Expiry_Key_Expires));
var key = GenerateKey(3);
var entity = CreateTestEntity(key);
// Act
await SetAsync(session, key, entity, TimeSpan.FromMilliseconds(100));
var beforeExpiry = await GetAsync(session, key);
await Task.Delay(200);
var afterExpiry = await GetAsync(session, key);
// Assert
beforeExpiry.Should().NotBeNull();
afterExpiry.Should().BeNull("key should have expired");
}
[Fact]
public async Task Concurrent_Sets_Same_Key_Last_Write_Wins()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Sets_Same_Key_Last_Write_Wins));
var key = GenerateKey(4);
// Act - Fire multiple concurrent sets
var tasks = Enumerable.Range(1, 10)
.Select(i => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
}));
await Task.WhenAll(tasks);
// Assert - Key should exist with some valid value
var result = await GetAsync(session, key);
result.Should().NotBeNull("one of the concurrent writes should succeed");
}
[Fact]
public async Task Get_Returns_Same_Value_Multiple_Times()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Get_Returns_Same_Value_Multiple_Times));
var key = GenerateKey(5);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var results = new List<string>();
for (int i = 0; i < 5; i++)
{
var result = await GetAsync(session, key);
results.Add(SerializeEntity(result!));
}
// Assert
results.Distinct().Should().HaveCount(1, "repeated gets should return identical values");
}
[Fact]
public async Task Exists_Returns_True_When_Key_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Exists_Returns_True_When_Key_Exists));
var key = GenerateKey(6);
var entity = CreateTestEntity(key);
await SetAsync(session, key, entity);
// Act
var exists = await ExistsAsync(session, key);
// Assert
exists.Should().BeTrue();
}
[Fact]
public async Task Exists_Returns_False_When_Key_Not_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Exists_Returns_False_When_Key_Not_Exists));
var key = GenerateKey(777);
// Act
var exists = await ExistsAsync(session, key);
// Assert
exists.Should().BeFalse();
}
}

View File

@@ -0,0 +1,257 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for query determinism tests.
/// Inherit from this class to verify that queries produce deterministic results.
/// </summary>
/// <typeparam name="TEntity">The entity type being queried.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected QueryDeterminismTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key, int sortValue = 0);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves all entities sorted by the primary ordering.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> GetAllSortedAsync(PostgresTestSession session, CancellationToken ct = default);
/// <summary>
/// Retrieves entities matching a filter, sorted.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> QueryFilteredAsync(PostgresTestSession session, Func<TEntity, bool> filter, CancellationToken ct = default);
/// <summary>
/// Retrieves entities with pagination.
/// </summary>
protected abstract Task<IReadOnlyList<TEntity>> GetPagedAsync(PostgresTestSession session, int skip, int take, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Gets the sort value from an entity for ordering verification.
/// </summary>
protected abstract int GetSortValue(TEntity entity);
/// <summary>
/// Serializes entity to a deterministic string representation.
/// </summary>
protected abstract string SerializeEntity(TEntity entity);
[Fact]
public async Task GetAll_Returns_Same_Order_Every_Time()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(GetAll_Returns_Same_Order_Every_Time));
var entities = Enumerable.Range(1, 20)
.Select(i => CreateTestEntity(GenerateKey(i), i))
.ToList();
// Insert in random order
var random = new Random(42); // Fixed seed for determinism
foreach (var entity in entities.OrderBy(_ => random.Next()))
{
await InsertAsync(session, entity);
}
// Act
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 5; i++)
{
results.Add(await GetAllSortedAsync(session));
}
// Assert
var firstResult = results[0].Select(SerializeEntity).ToList();
foreach (var result in results.Skip(1))
{
var serialized = result.Select(SerializeEntity).ToList();
serialized.Should().BeEquivalentTo(firstResult, options => options.WithStrictOrdering(),
"query should return same order every time");
}
}
[Fact]
public async Task GetAll_Is_Sorted_Correctly()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(GetAll_Is_Sorted_Correctly));
var entities = Enumerable.Range(1, 10)
.Select(i => CreateTestEntity(GenerateKey(i), i * 10))
.ToList();
// Insert in reverse order
foreach (var entity in entities.AsEnumerable().Reverse())
{
await InsertAsync(session, entity);
}
// Act
var result = await GetAllSortedAsync(session);
// Assert
var sortValues = result.Select(GetSortValue).ToList();
sortValues.Should().BeInAscendingOrder("results should be sorted by sort value");
}
[Fact]
public async Task Filtered_Query_Returns_Deterministic_Results()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Filtered_Query_Returns_Deterministic_Results));
for (int i = 1; i <= 30; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i));
}
// Act
Func<TEntity, bool> filter = e => GetSortValue(e) % 2 == 0; // Even values
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 3; i++)
{
results.Add(await QueryFilteredAsync(session, filter));
}
// Assert
var firstSerialized = results[0].Select(SerializeEntity).ToList();
foreach (var result in results.Skip(1))
{
var serialized = result.Select(SerializeEntity).ToList();
serialized.Should().BeEquivalentTo(firstSerialized, options => options.WithStrictOrdering());
}
}
[Fact]
public async Task Pagination_Returns_Consistent_Pages()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Pagination_Returns_Consistent_Pages));
for (int i = 1; i <= 50; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i));
}
// Act
var page1A = await GetPagedAsync(session, 0, 10);
var page1B = await GetPagedAsync(session, 0, 10);
var page2A = await GetPagedAsync(session, 10, 10);
var page2B = await GetPagedAsync(session, 10, 10);
// Assert
page1A.Select(SerializeEntity).Should().BeEquivalentTo(
page1B.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"same page should return same results");
page2A.Select(SerializeEntity).Should().BeEquivalentTo(
page2B.Select(SerializeEntity),
options => options.WithStrictOrdering());
// Pages should not overlap
var page1Keys = page1A.Select(GetSortValue).ToHashSet();
var page2Keys = page2A.Select(GetSortValue).ToHashSet();
page1Keys.Intersect(page2Keys).Should().BeEmpty("pages should not overlap");
}
[Fact]
public async Task Query_After_Insert_Returns_Updated_Results_Deterministically()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Query_After_Insert_Returns_Updated_Results_Deterministically));
for (int i = 1; i <= 10; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i * 10));
}
// Get baseline
var baseline = await GetAllSortedAsync(session);
baseline.Should().HaveCount(10);
// Act - Insert more
for (int i = 11; i <= 15; i++)
{
await InsertAsync(session, CreateTestEntity(GenerateKey(i), i * 10));
}
var after1 = await GetAllSortedAsync(session);
var after2 = await GetAllSortedAsync(session);
// Assert
after1.Should().HaveCount(15);
after1.Select(SerializeEntity).Should().BeEquivalentTo(
after2.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"queries after insert should be consistent");
}
[Fact]
public async Task Empty_Query_Returns_Empty_Deterministically()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Empty_Query_Returns_Empty_Deterministically));
// Don't insert anything
// Act
var results = new List<IReadOnlyList<TEntity>>();
for (int i = 0; i < 3; i++)
{
results.Add(await GetAllSortedAsync(session));
}
// Assert
foreach (var result in results)
{
result.Should().BeEmpty("empty table should return empty results");
}
}
[Fact]
public async Task Large_Result_Set_Maintains_Deterministic_Order()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Large_Result_Set_Maintains_Deterministic_Order));
var random = new Random(12345);
var entities = Enumerable.Range(1, 100)
.Select(i => CreateTestEntity(GenerateKey(i), random.Next(1, 1000)))
.ToList();
foreach (var entity in entities)
{
await InsertAsync(session, entity);
}
// Act
var result1 = await GetAllSortedAsync(session);
var result2 = await GetAllSortedAsync(session);
// Assert
result1.Select(SerializeEntity).Should().BeEquivalentTo(
result2.Select(SerializeEntity),
options => options.WithStrictOrdering(),
"large result sets should maintain deterministic order");
}
}

View File

@@ -0,0 +1,222 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for storage concurrency tests.
/// Inherit from this class to verify that storage operations handle concurrency correctly.
/// </summary>
/// <typeparam name="TEntity">The entity type being stored.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected StorageConcurrencyTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key, int version = 1);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Updates the entity in storage.
/// </summary>
protected abstract Task<TEntity> UpdateAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves the entity from storage by key.
/// </summary>
protected abstract Task<TEntity?> GetByKeyAsync(PostgresTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Gets the version/timestamp from an entity for optimistic concurrency.
/// </summary>
protected abstract int GetVersion(TEntity entity);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
/// <summary>
/// Default concurrency level for tests.
/// </summary>
protected virtual int DefaultConcurrency => 10;
[Fact]
public async Task Concurrent_Inserts_Different_Keys_Should_All_Succeed()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Inserts_Different_Keys_Should_All_Succeed));
var entities = Enumerable.Range(1, DefaultConcurrency)
.Select(i => CreateTestEntity(GenerateKey(i)))
.ToList();
// Act
var tasks = entities.Select(e => Task.Run(async () => await InsertAsync(session, e)));
await Task.WhenAll(tasks);
// Assert
foreach (var entity in entities)
{
var key = GenerateKey(entities.IndexOf(entity) + 1);
var retrieved = await GetByKeyAsync(session, key);
retrieved.Should().NotBeNull();
}
}
[Fact]
public async Task Concurrent_Updates_Same_Key_Should_Not_Lose_Updates()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Updates_Same_Key_Should_Not_Lose_Updates));
var key = GenerateKey(100);
var initial = CreateTestEntity(key, 0);
await InsertAsync(session, initial);
// Act
var successCount = 0;
var tasks = Enumerable.Range(1, DefaultConcurrency)
.Select(i => Task.Run(async () =>
{
try
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
Interlocked.Increment(ref successCount);
}
catch
{
// Some updates may fail due to optimistic concurrency
}
}));
await Task.WhenAll(tasks);
// Assert
successCount.Should().BeGreaterThan(0, "at least some updates should succeed");
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull();
}
[Fact]
public async Task Read_During_Write_Should_Return_Consistent_Data()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Read_During_Write_Should_Return_Consistent_Data));
var key = GenerateKey(200);
var initial = CreateTestEntity(key, 1);
await InsertAsync(session, initial);
// Act
var readResults = new List<TEntity?>();
var readTask = Task.Run(async () =>
{
for (int i = 0; i < 20; i++)
{
var result = await GetByKeyAsync(session, key);
lock (readResults)
{
readResults.Add(result);
}
await Task.Delay(10);
}
});
var writeTask = Task.Run(async () =>
{
for (int i = 2; i <= 10; i++)
{
var entity = CreateTestEntity(key, i);
await UpdateAsync(session, entity);
await Task.Delay(15);
}
});
await Task.WhenAll(readTask, writeTask);
// Assert
readResults.Should().NotBeEmpty();
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
}
[Fact]
public async Task Parallel_Operations_Should_Maintain_Data_Integrity()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Parallel_Operations_Should_Maintain_Data_Integrity));
var keys = Enumerable.Range(1, 5).Select(GenerateKey).ToList();
// Insert initial entities
foreach (var key in keys)
{
await InsertAsync(session, CreateTestEntity(key, 1));
}
// Act
var operations = new List<Task>();
for (int round = 0; round < 3; round++)
{
foreach (var key in keys)
{
operations.Add(Task.Run(async () =>
{
// Read
var entity = await GetByKeyAsync(session, key);
if (entity != null)
{
// Update
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
await UpdateAsync(session, updated);
}
}));
}
}
await Task.WhenAll(operations);
// Assert
foreach (var key in keys)
{
var final = await GetByKeyAsync(session, key);
final.Should().NotBeNull("entity should exist after parallel operations");
}
}
[Fact]
public async Task High_Concurrency_Batch_Insert_Should_Complete()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(High_Concurrency_Batch_Insert_Should_Complete));
var entityCount = DefaultConcurrency * 10;
var entities = Enumerable.Range(1, entityCount)
.Select(i => CreateTestEntity(GenerateKey(i + 1000)))
.ToList();
// Act
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = DefaultConcurrency };
await Parallel.ForEachAsync(entities, parallelOptions, async (entity, ct) =>
{
await InsertAsync(session, entity, ct);
});
// Assert
// All inserts should complete without deadlock or timeout
var sample = await GetByKeyAsync(session, GenerateKey(1001));
sample.Should().NotBeNull();
}
}

View File

@@ -0,0 +1,151 @@
using StellaOps.TestKit.Fixtures;
using FluentAssertions;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for storage idempotency tests.
/// Inherit from this class to verify that storage operations are idempotent.
/// </summary>
/// <typeparam name="TEntity">The entity type being stored.</typeparam>
/// <typeparam name="TKey">The key type for the entity.</typeparam>
public abstract class StorageIdempotencyTests<TEntity, TKey> : IClassFixture<PostgresFixture>
where TEntity : class
where TKey : notnull
{
protected readonly PostgresFixture Fixture;
protected StorageIdempotencyTests(PostgresFixture fixture)
{
Fixture = fixture;
Fixture.IsolationMode = PostgresIsolationMode.SchemaPerTest;
}
/// <summary>
/// Creates a test entity with deterministic values.
/// </summary>
protected abstract TEntity CreateTestEntity(TKey key);
/// <summary>
/// Gets the key from an entity.
/// </summary>
protected abstract TKey GetKey(TEntity entity);
/// <summary>
/// Inserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> InsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Upserts the entity into storage.
/// </summary>
protected abstract Task<TEntity> UpsertAsync(PostgresTestSession session, TEntity entity, CancellationToken ct = default);
/// <summary>
/// Retrieves the entity from storage by key.
/// </summary>
protected abstract Task<TEntity?> GetByKeyAsync(PostgresTestSession session, TKey key, CancellationToken ct = default);
/// <summary>
/// Counts all entities in storage.
/// </summary>
protected abstract Task<int> CountAsync(PostgresTestSession session, CancellationToken ct = default);
/// <summary>
/// Generates a deterministic key for testing.
/// </summary>
protected abstract TKey GenerateKey(int seed);
[Fact]
public async Task Insert_SameEntity_Twice_Should_Be_Idempotent()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Insert_SameEntity_Twice_Should_Be_Idempotent));
var key = GenerateKey(1);
var entity = CreateTestEntity(key);
// Act
var first = await InsertAsync(session, entity);
var second = await UpsertAsync(session, entity);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "idempotent insert should not create duplicates");
}
[Fact]
public async Task Upsert_Creates_When_Not_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Upsert_Creates_When_Not_Exists));
var key = GenerateKey(2);
var entity = CreateTestEntity(key);
// Act
var result = await UpsertAsync(session, entity);
// Assert
var retrieved = await GetByKeyAsync(session, key);
retrieved.Should().NotBeNull();
}
[Fact]
public async Task Upsert_Updates_When_Exists()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Upsert_Updates_When_Exists));
var key = GenerateKey(3);
var entity = CreateTestEntity(key);
// Act
await InsertAsync(session, entity);
var modified = CreateTestEntity(key);
var result = await UpsertAsync(session, modified);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "upsert should update existing, not create duplicate");
}
[Fact]
public async Task Multiple_Upserts_Same_Key_Produces_Single_Record()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Multiple_Upserts_Same_Key_Produces_Single_Record));
var key = GenerateKey(4);
// Act
for (int i = 0; i < 5; i++)
{
var entity = CreateTestEntity(key);
await UpsertAsync(session, entity);
}
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "repeated upserts should not create duplicates");
}
[Fact]
public async Task Concurrent_Upserts_Same_Key_Should_Not_Fail()
{
// Arrange
await using var session = await Fixture.CreateSessionAsync(nameof(Concurrent_Upserts_Same_Key_Should_Not_Fail));
var key = GenerateKey(5);
// Act
var tasks = Enumerable.Range(0, 10)
.Select(_ => Task.Run(async () =>
{
var entity = CreateTestEntity(key);
await UpsertAsync(session, entity);
}));
await Task.WhenAll(tasks);
// Assert
var count = await CountAsync(session);
count.Should().Be(1, "concurrent upserts should resolve to single record");
}
}

View File

@@ -0,0 +1,325 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.TestKit.Extensions;
using StellaOps.TestKit.Observability;
using Xunit;
namespace StellaOps.TestKit.Templates;
/// <summary>
/// Base class for web service contract tests.
/// Provides OpenAPI schema validation and standard test patterns.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceContractTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceContractTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
/// <summary>
/// Gets the path to the OpenAPI schema snapshot.
/// </summary>
protected abstract string OpenApiSnapshotPath { get; }
/// <summary>
/// Gets the Swagger endpoint path.
/// </summary>
protected virtual string SwaggerEndpoint => "/swagger/v1/swagger.json";
/// <summary>
/// Gets the expected endpoints that must exist.
/// </summary>
protected abstract IEnumerable<string> RequiredEndpoints { get; }
/// <summary>
/// Gets the endpoints requiring authentication.
/// </summary>
protected abstract IEnumerable<string> AuthenticatedEndpoints { get; }
[Fact]
public virtual async Task OpenApiSchema_MatchesSnapshot()
{
await Fixtures.ContractTestHelper.ValidateOpenApiSchemaAsync(
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
}
[Fact]
public virtual async Task OpenApiSchema_ContainsRequiredEndpoints()
{
await Fixtures.ContractTestHelper.ValidateEndpointsExistAsync(
Factory, RequiredEndpoints, SwaggerEndpoint);
}
[Fact]
public virtual async Task OpenApiSchema_HasNoBreakingChanges()
{
var changes = await Fixtures.ContractTestHelper.DetectBreakingChangesAsync(
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
changes.HasBreakingChanges.Should().BeFalse(
$"Breaking changes detected: {string.Join(", ", changes.BreakingChanges)}");
}
public void Dispose()
{
if (_disposed) return;
OtelCapture.Dispose();
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service negative tests.
/// Tests malformed requests, oversized payloads, wrong methods, etc.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceNegativeTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
private bool _disposed;
protected WebServiceNegativeTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
}
/// <summary>
/// Gets test cases for malformed content type (endpoint, expected status).
/// </summary>
protected abstract IEnumerable<(string Endpoint, HttpStatusCode ExpectedStatus)> MalformedContentTypeTestCases { get; }
/// <summary>
/// Gets test cases for oversized payloads.
/// </summary>
protected abstract IEnumerable<(string Endpoint, int PayloadSizeBytes)> OversizedPayloadTestCases { get; }
/// <summary>
/// Gets test cases for method mismatch.
/// </summary>
protected abstract IEnumerable<(string Endpoint, HttpMethod ExpectedMethod)> MethodMismatchTestCases { get; }
[Fact]
public virtual async Task MalformedContentType_Returns415()
{
foreach (var (endpoint, expectedStatus) in MalformedContentTypeTestCases)
{
var response = await Client.SendWithMalformedContentTypeAsync(
HttpMethod.Post, endpoint, "{}");
response.StatusCode.Should().Be(expectedStatus,
$"endpoint {endpoint} should return {expectedStatus} for malformed content type");
}
}
[Fact]
public virtual async Task OversizedPayload_Returns413()
{
foreach (var (endpoint, sizeBytes) in OversizedPayloadTestCases)
{
var response = await Client.SendOversizedPayloadAsync(endpoint, sizeBytes);
response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge,
$"endpoint {endpoint} should return 413 for oversized payload ({sizeBytes} bytes)");
}
}
[Fact]
public virtual async Task WrongHttpMethod_Returns405()
{
foreach (var (endpoint, expectedMethod) in MethodMismatchTestCases)
{
var response = await Client.SendWithWrongMethodAsync(endpoint, expectedMethod);
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
$"endpoint {endpoint} should return 405 when called with wrong method");
}
}
public void Dispose()
{
if (_disposed) return;
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service auth/authz tests.
/// Tests deny-by-default, token expiry, tenant isolation.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceAuthTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
private bool _disposed;
protected WebServiceAuthTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
}
/// <summary>
/// Gets endpoints that require authentication.
/// </summary>
protected abstract IEnumerable<string> ProtectedEndpoints { get; }
/// <summary>
/// Generates a valid token for the given tenant.
/// </summary>
protected abstract string GenerateValidToken(string tenantId);
/// <summary>
/// Generates an expired token.
/// </summary>
protected abstract string GenerateExpiredToken();
/// <summary>
/// Generates a token for a different tenant (for isolation tests).
/// </summary>
protected abstract string GenerateOtherTenantToken(string otherTenantId);
[Fact]
public virtual async Task ProtectedEndpoints_WithoutAuth_Returns401()
{
using var client = Factory.CreateClient();
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.SendWithoutAuthAsync(HttpMethod.Get, endpoint);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should require authentication");
}
}
[Fact]
public virtual async Task ProtectedEndpoints_WithExpiredToken_Returns401()
{
using var client = Factory.CreateClient();
var expiredToken = GenerateExpiredToken();
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.SendWithExpiredTokenAsync(endpoint, expiredToken);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should reject expired tokens");
}
}
[Fact]
public virtual async Task ProtectedEndpoints_WithValidToken_ReturnsSuccess()
{
using var client = Factory.CreateClient();
var validToken = GenerateValidToken("test-tenant");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", validToken);
foreach (var endpoint in ProtectedEndpoints)
{
var response = await client.GetAsync(endpoint);
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized,
$"endpoint {endpoint} should accept valid tokens");
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Base class for web service OTel trace tests.
/// Validates that traces are emitted with required attributes.
/// </summary>
/// <typeparam name="TProgram">The program entry point class.</typeparam>
public abstract class WebServiceOtelTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory<TProgram> Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceOtelTestBase(WebApplicationFactory<TProgram> factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
/// <summary>
/// Gets endpoints and their expected span names.
/// </summary>
protected abstract IEnumerable<(string Endpoint, string ExpectedSpanName)> TracedEndpoints { get; }
/// <summary>
/// Gets required trace attributes for all spans.
/// </summary>
protected abstract IEnumerable<string> RequiredTraceAttributes { get; }
[Fact]
public virtual async Task Endpoints_EmitTraces()
{
foreach (var (endpoint, expectedSpan) in TracedEndpoints)
{
var capture = new OtelCapture();
var response = await Client.GetAsync(endpoint);
capture.AssertHasSpan(expectedSpan);
capture.Dispose();
}
}
[Fact]
public virtual async Task Traces_ContainRequiredAttributes()
{
foreach (var (endpoint, _) in TracedEndpoints)
{
var capture = new OtelCapture();
await Client.GetAsync(endpoint);
foreach (var attr in RequiredTraceAttributes)
{
capture.CapturedActivities.Should().Contain(a =>
a.Tags.Any(t => t.Key == attr),
$"trace for {endpoint} should have attribute '{attr}'");
}
capture.Dispose();
}
}
public void Dispose()
{
if (_disposed) return;
OtelCapture.Dispose();
Client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}