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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user