audit, advisories and doctors/setup work
This commit is contained in:
@@ -14,7 +14,7 @@ namespace StellaOps.TestKit.Assertions;
|
||||
/// - Consistent number formatting
|
||||
/// - No whitespace variations
|
||||
/// - UTF-8 encoding
|
||||
/// - Deterministic output (same input → same bytes)
|
||||
/// - Deterministic output (same input -> same bytes)
|
||||
/// </remarks>
|
||||
public static class CanonicalJsonAssert
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
@@ -9,8 +10,11 @@ namespace StellaOps.TestKit.Connectors;
|
||||
/// </summary>
|
||||
public sealed class ConnectorHttpFixture : IDisposable
|
||||
{
|
||||
private const string ClientName = "ConnectorHttpFixture";
|
||||
private readonly Dictionary<string, HttpResponseEntry> _responses = new();
|
||||
private readonly List<HttpRequestMessage> _capturedRequests = new();
|
||||
private ServiceProvider? _serviceProvider;
|
||||
private IHttpClientFactory? _httpClientFactory;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,7 +27,7 @@ public sealed class ConnectorHttpFixture : IDisposable
|
||||
/// </summary>
|
||||
public HttpClient CreateClient()
|
||||
{
|
||||
return new HttpClient(new CannedMessageHandler(this));
|
||||
return GetClientFactory().CreateClient(ClientName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,9 +167,28 @@ public sealed class ConnectorHttpFixture : IDisposable
|
||||
if (_disposed) return;
|
||||
_responses.Clear();
|
||||
_capturedRequests.Clear();
|
||||
_serviceProvider?.Dispose();
|
||||
_serviceProvider = null;
|
||||
_httpClientFactory = null;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private IHttpClientFactory GetClientFactory()
|
||||
{
|
||||
if (_httpClientFactory != null)
|
||||
{
|
||||
return _httpClientFactory;
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new CannedMessageHandler(this));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
|
||||
return _httpClientFactory;
|
||||
}
|
||||
|
||||
private sealed record HttpResponseEntry(
|
||||
HttpStatusCode StatusCode = HttpStatusCode.OK,
|
||||
string ContentType = "application/json",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
@@ -34,19 +34,31 @@ namespace StellaOps.TestKit.Connectors;
|
||||
/// </remarks>
|
||||
public abstract class ConnectorLiveSchemaTestBase : IAsyncLifetime
|
||||
{
|
||||
private const string LiveClientName = "ConnectorLiveSchema";
|
||||
private static readonly Lazy<IServiceProvider> LiveServices = new(() =>
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(LiveClientName)
|
||||
.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
return services.BuildServiceProvider();
|
||||
});
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly FixtureUpdater _fixtureUpdater;
|
||||
private readonly List<FixtureDriftReport> _driftReports = new();
|
||||
|
||||
protected ConnectorLiveSchemaTestBase()
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_httpClient = LiveHttpClientFactory.CreateClient(LiveClientName);
|
||||
_fixtureUpdater = new FixtureUpdater(FixturesDirectory, _httpClient);
|
||||
}
|
||||
|
||||
private static IHttpClientFactory LiveHttpClientFactory =>
|
||||
LiveServices.Value.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base directory for test fixtures (relative to test assembly).
|
||||
/// </summary>
|
||||
|
||||
@@ -12,10 +12,10 @@ public sealed class FixtureUpdater
|
||||
private readonly string _fixturesDirectory;
|
||||
private readonly bool _enabled;
|
||||
|
||||
public FixtureUpdater(string fixturesDirectory, HttpClient? httpClient = null)
|
||||
public FixtureUpdater(string fixturesDirectory, HttpClient httpClient)
|
||||
{
|
||||
_fixturesDirectory = fixturesDirectory;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_enabled = Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,11 @@ public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram
|
||||
/// .WhenRequest("https://api.example.com/data")
|
||||
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
|
||||
///
|
||||
/// var httpClient = new HttpClient(handler);
|
||||
/// var services = new ServiceCollection();
|
||||
/// services.AddHttpClient("stub")
|
||||
/// .ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||
/// using var provider = services.BuildServiceProvider();
|
||||
/// var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient("stub");
|
||||
/// var response = await httpClient.GetAsync("https://api.example.com/data");
|
||||
/// // response.StatusCode == HttpStatusCode.OK
|
||||
/// </code>
|
||||
|
||||
@@ -43,11 +43,10 @@ public enum ValkeyIsolationMode
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
|
||||
public sealed class ValkeyFixture : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _container;
|
||||
private ConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
private int _databaseCounter;
|
||||
|
||||
/// <summary>
|
||||
@@ -206,19 +205,6 @@ public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the fixture.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DisposeAsync().GetAwaiter().GetResult();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
@@ -38,6 +39,7 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Add default test services
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TestRequestContext>();
|
||||
|
||||
// Apply custom configuration
|
||||
@@ -81,12 +83,18 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
|
||||
public sealed class TestRequestContext
|
||||
{
|
||||
private readonly List<RequestRecord> _requests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TestRequestContext(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public void RecordRequest(string method, string path, int statusCode)
|
||||
{
|
||||
lock (_requests)
|
||||
{
|
||||
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
|
||||
_requests.Add(new RequestRecord(method, path, statusCode, _timeProvider.GetUtcNow().UtcDateTime));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,11 +155,7 @@ public abstract class CacheIdempotencyTests<TEntity, TKey> : IClassFixture<Valke
|
||||
|
||||
// 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);
|
||||
}));
|
||||
.Select(_ => SetAsync(session, key, CreateTestEntity(key)));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
|
||||
namespace StellaOps.TestKit.Templates;
|
||||
|
||||
@@ -17,13 +18,13 @@ namespace StellaOps.TestKit.Templates;
|
||||
/// <remarks>
|
||||
/// Common sources of test flakiness and their solutions:
|
||||
///
|
||||
/// 1. **DateTime.Now/UtcNow** → Use injected TimeProvider or DeterministicTime
|
||||
/// 2. **Random without seed** → Use DeterministicRandom with fixed seed
|
||||
/// 3. **Task.Delay for timing** → Use polling with configurable timeout or fake timers
|
||||
/// 4. **External service calls** → Use HttpFixtureServer or mocks
|
||||
/// 5. **Ordering assumptions** → Ensure explicit ORDER BY or use sorted assertions
|
||||
/// 6. **Parallel test interference** → Use test isolation (schema-per-test, unique IDs)
|
||||
/// 7. **Environment dependencies** → Use TestContainers with fixed versions
|
||||
/// 1. **DateTime.Now/UtcNow** -> Use injected TimeProvider or DeterministicTime
|
||||
/// 2. **Random without seed** -> Use DeterministicRandom with fixed seed
|
||||
/// 3. **Task.Delay for timing** -> Use polling with configurable timeout or fake timers
|
||||
/// 4. **External service calls** -> Use HttpFixtureServer or mocks
|
||||
/// 5. **Ordering assumptions** -> Ensure explicit ORDER BY or use sorted assertions
|
||||
/// 6. **Parallel test interference** -> Use test isolation (schema-per-test, unique IDs)
|
||||
/// 7. **Environment dependencies** -> Use TestContainers with fixed versions
|
||||
/// </remarks>
|
||||
public static class FlakyToDeterministicPattern
|
||||
{
|
||||
@@ -32,7 +33,7 @@ public static class FlakyToDeterministicPattern
|
||||
// FLAKY: Uses system clock - different results on each run
|
||||
// public void Flaky_DateTimeNow()
|
||||
// {
|
||||
// var record = new AuditRecord { CreatedAt = DateTime.UtcNow };
|
||||
// var record = new AuditRecord { CreatedAt = GetSystemUtcNow() };
|
||||
// Assert.True(record.CreatedAt.Hour == 12); // Fails at any other hour
|
||||
// }
|
||||
|
||||
@@ -59,7 +60,7 @@ public static class FlakyToDeterministicPattern
|
||||
// FLAKY: Different random sequence each run
|
||||
// public void Flaky_Random()
|
||||
// {
|
||||
// var random = new Random();
|
||||
// var random = CreateUnseededRandom();
|
||||
// var value = random.Next(1, 100);
|
||||
// Assert.Equal(42, value); // Almost never passes
|
||||
// }
|
||||
@@ -70,7 +71,7 @@ public static class FlakyToDeterministicPattern
|
||||
public static int Deterministic_SeededRandom(int seed = 12345)
|
||||
{
|
||||
// Same seed always produces same sequence
|
||||
var random = new Random(seed);
|
||||
var random = new DeterministicRandom(seed);
|
||||
return random.Next(1, 100); // Always returns same value for same seed
|
||||
}
|
||||
|
||||
@@ -129,9 +130,9 @@ public static class FlakyToDeterministicPattern
|
||||
#region Pattern 4: Replace External HTTP with Fixture Server
|
||||
|
||||
// FLAKY: Depends on external service availability
|
||||
// public async Task Flaky_ExternalHttp()
|
||||
// public async Task Flaky_ExternalHttp(IHttpClientFactory httpClientFactory)
|
||||
// {
|
||||
// var client = new HttpClient();
|
||||
// var client = httpClientFactory.CreateClient("live");
|
||||
// var response = await client.GetAsync("https://api.example.com/data");
|
||||
// Assert.True(response.IsSuccessStatusCode);
|
||||
// }
|
||||
@@ -207,12 +208,14 @@ public static class FlakyToDeterministicPattern
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic version with unique identifiers.
|
||||
/// Deterministic version with seeded identifiers.
|
||||
/// </summary>
|
||||
public static string GenerateTestId(string testName)
|
||||
public static string GenerateTestId(string testName, DeterministicRandom random)
|
||||
{
|
||||
// Each test gets unique ID based on test name + timestamp
|
||||
return $"{testName}-{Guid.NewGuid():N}";
|
||||
ArgumentNullException.ThrowIfNull(testName);
|
||||
ArgumentNullException.ThrowIfNull(random);
|
||||
// Each test gets deterministic ID based on test name + seeded random
|
||||
return $"{testName}-{random.NextGuid():N}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
@@ -72,7 +73,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
|
||||
.ToList();
|
||||
|
||||
// Insert in random order
|
||||
var random = new Random(42); // Fixed seed for determinism
|
||||
var random = new DeterministicRandom(42);
|
||||
foreach (var entity in entities.OrderBy(_ => random.Next()))
|
||||
{
|
||||
await InsertAsync(session, entity);
|
||||
@@ -234,7 +235,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
|
||||
{
|
||||
// Arrange
|
||||
await using var session = await Fixture.CreateSessionAsync(nameof(Large_Result_Set_Maintains_Deterministic_Order));
|
||||
var random = new Random(12345);
|
||||
var random = new DeterministicRandom(12345);
|
||||
var entities = Enumerable.Range(1, 100)
|
||||
.Select(i => CreateTestEntity(GenerateKey(i), random.Next(1, 1000)))
|
||||
.ToList();
|
||||
|
||||
@@ -67,7 +67,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
var tasks = entities.Select(e => Task.Run(async () => await InsertAsync(session, e)));
|
||||
var tasks = entities.Select(e => InsertAsync(session, e));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
@@ -91,19 +91,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
// 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
|
||||
}
|
||||
}));
|
||||
.Select(UpdateSafelyAsync);
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
@@ -111,6 +99,20 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
successCount.Should().BeGreaterThan(0, "at least some updates should succeed");
|
||||
var final = await GetByKeyAsync(session, key);
|
||||
final.Should().NotBeNull();
|
||||
|
||||
async Task UpdateSafelyAsync(int i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = CreateTestEntity(key, i);
|
||||
await UpdateAsync(session, entity);
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some updates may fail due to optimistic concurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -124,7 +126,16 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
|
||||
// Act
|
||||
var readResults = new List<TEntity?>();
|
||||
var readTask = Task.Run(async () =>
|
||||
var readTask = ReadLoopAsync();
|
||||
var writeTask = WriteLoopAsync();
|
||||
|
||||
await Task.WhenAll(readTask, writeTask);
|
||||
|
||||
// Assert
|
||||
readResults.Should().NotBeEmpty();
|
||||
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
|
||||
|
||||
async Task ReadLoopAsync()
|
||||
{
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
@@ -135,9 +146,9 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
}
|
||||
await Task.Delay(10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var writeTask = Task.Run(async () =>
|
||||
async Task WriteLoopAsync()
|
||||
{
|
||||
for (int i = 2; i <= 10; i++)
|
||||
{
|
||||
@@ -145,13 +156,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
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]
|
||||
@@ -173,17 +178,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
{
|
||||
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);
|
||||
}
|
||||
}));
|
||||
operations.Add(RunOperationAsync(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +190,18 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
var final = await GetByKeyAsync(session, key);
|
||||
final.Should().NotBeNull("entity should exist after parallel operations");
|
||||
}
|
||||
|
||||
async Task RunOperationAsync(TKey key)
|
||||
{
|
||||
// Read
|
||||
var entity = await GetByKeyAsync(session, key);
|
||||
if (entity != null)
|
||||
{
|
||||
// Update
|
||||
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
|
||||
await UpdateAsync(session, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -136,11 +136,7 @@ public abstract class StorageIdempotencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
|
||||
// Act
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => Task.Run(async () =>
|
||||
{
|
||||
var entity = CreateTestEntity(key);
|
||||
await UpsertAsync(session, entity);
|
||||
}));
|
||||
.Select(_ => UpsertAsync(session, CreateTestEntity(key)));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user