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,130 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Assertions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides assertions for canonical JSON serialization and determinism testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Canonical JSON ensures:
|
||||
/// - Stable key ordering (alphabetical)
|
||||
/// - Consistent number formatting
|
||||
/// - No whitespace variations
|
||||
/// - UTF-8 encoding
|
||||
/// - Deterministic output (same input → same bytes)
|
||||
/// </remarks>
|
||||
public static class CanonicalJsonAssert
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that the canonical JSON serialization of the value produces the expected SHA-256 hash.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to serialize.</param>
|
||||
/// <param name="expectedSha256Hex">The expected SHA-256 hash (lowercase hex string).</param>
|
||||
public static void HasExpectedHash<T>(T value, string expectedSha256Hex)
|
||||
{
|
||||
string actualHash = Canonical.Json.CanonJson.Hash(value);
|
||||
Assert.Equal(expectedSha256Hex.ToLowerInvariant(), actualHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that two values produce identical canonical JSON.
|
||||
/// </summary>
|
||||
public static void AreCanonicallyEqual<T>(T expected, T actual)
|
||||
{
|
||||
byte[] expectedBytes = Canonical.Json.CanonJson.Canonicalize(expected);
|
||||
byte[] actualBytes = Canonical.Json.CanonJson.Canonicalize(actual);
|
||||
|
||||
Assert.Equal(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that serializing the value multiple times produces identical bytes (determinism check).
|
||||
/// </summary>
|
||||
public static void IsDeterministic<T>(T value, int iterations = 10)
|
||||
{
|
||||
byte[]? baseline = null;
|
||||
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
byte[] current = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
|
||||
if (baseline == null)
|
||||
{
|
||||
baseline = current;
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(baseline, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 hash of the canonical JSON and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
public static string ComputeCanonicalHash<T>(T value)
|
||||
{
|
||||
return Canonical.Json.CanonJson.Hash(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the canonical JSON matches the expected string (useful for debugging).
|
||||
/// </summary>
|
||||
public static void MatchesJson<T>(T value, string expectedJson)
|
||||
{
|
||||
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
string actualJson = System.Text.Encoding.UTF8.GetString(canonicalBytes);
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the JSON contains the expected key-value pair (deep search).
|
||||
/// </summary>
|
||||
public static void ContainsProperty<T>(T value, string propertyPath, object expectedValue)
|
||||
{
|
||||
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
using var doc = JsonDocument.Parse(canonicalBytes);
|
||||
|
||||
JsonElement? element = FindPropertyByPath(doc.RootElement, propertyPath);
|
||||
|
||||
Assert.NotNull(element);
|
||||
|
||||
// Compare values
|
||||
string expectedJson = JsonSerializer.Serialize(expectedValue);
|
||||
string actualJson = element.Value.GetRawText();
|
||||
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
private static JsonElement? FindPropertyByPath(JsonElement root, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!current.TryGetProperty(part, out var next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] data)
|
||||
{
|
||||
byte[] hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
114
src/__Libraries/StellaOps.TestKit/Assertions/SnapshotAssert.cs
Normal file
114
src/__Libraries/StellaOps.TestKit/Assertions/SnapshotAssert.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Assertions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides snapshot testing assertions for golden master testing.
|
||||
/// Snapshots are stored in the test project's `Snapshots/` directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// [Fact]
|
||||
/// public void TestSbomGeneration()
|
||||
/// {
|
||||
/// var sbom = GenerateSbom();
|
||||
///
|
||||
/// // Snapshot will be stored in Snapshots/TestSbomGeneration.json
|
||||
/// SnapshotAssert.MatchesSnapshot(sbom, snapshotName: "TestSbomGeneration");
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// To update snapshots (e.g., after intentional changes), set environment variable:
|
||||
/// UPDATE_SNAPSHOTS=1 dotnet test
|
||||
/// </remarks>
|
||||
public static class SnapshotAssert
|
||||
{
|
||||
private static readonly bool UpdateSnapshotsMode =
|
||||
Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the value matches the stored snapshot. If UPDATE_SNAPSHOTS=1, updates the snapshot.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to snapshot (will be JSON-serialized).</param>
|
||||
/// <param name="snapshotName">The snapshot name (filename without extension).</param>
|
||||
/// <param name="snapshotsDirectory">Optional directory for snapshots (default: "Snapshots" in test project).</param>
|
||||
public static void MatchesSnapshot<T>(T value, string snapshotName, string? snapshotsDirectory = null)
|
||||
{
|
||||
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
|
||||
Directory.CreateDirectory(snapshotsDirectory);
|
||||
|
||||
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.json");
|
||||
|
||||
// Serialize to pretty JSON for readability
|
||||
string actualJson = JsonSerializer.Serialize(value, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
if (UpdateSnapshotsMode)
|
||||
{
|
||||
// Update snapshot
|
||||
File.WriteAllText(snapshotPath, actualJson, Encoding.UTF8);
|
||||
return; // Don't assert in update mode
|
||||
}
|
||||
|
||||
// Verify snapshot exists
|
||||
Assert.True(File.Exists(snapshotPath),
|
||||
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
|
||||
|
||||
// Compare with stored snapshot
|
||||
string expectedJson = File.ReadAllText(snapshotPath, Encoding.UTF8);
|
||||
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the text matches the stored snapshot.
|
||||
/// </summary>
|
||||
public static void MatchesTextSnapshot(string value, string snapshotName, string? snapshotsDirectory = null)
|
||||
{
|
||||
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
|
||||
Directory.CreateDirectory(snapshotsDirectory);
|
||||
|
||||
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.txt");
|
||||
|
||||
if (UpdateSnapshotsMode)
|
||||
{
|
||||
File.WriteAllText(snapshotPath, value, Encoding.UTF8);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(snapshotPath),
|
||||
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
|
||||
|
||||
string expected = File.ReadAllText(snapshotPath, Encoding.UTF8);
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that binary data matches the stored snapshot.
|
||||
/// </summary>
|
||||
public static void MatchesBinarySnapshot(byte[] value, string snapshotName, string? snapshotsDirectory = null)
|
||||
{
|
||||
snapshotsDirectory ??= Path.Combine(Directory.GetCurrentDirectory(), "Snapshots");
|
||||
Directory.CreateDirectory(snapshotsDirectory);
|
||||
|
||||
string snapshotPath = Path.Combine(snapshotsDirectory, $"{snapshotName}.bin");
|
||||
|
||||
if (UpdateSnapshotsMode)
|
||||
{
|
||||
File.WriteAllBytes(snapshotPath, value);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(snapshotPath),
|
||||
$"Snapshot '{snapshotName}' not found at {snapshotPath}. Run with UPDATE_SNAPSHOTS=1 to create it.");
|
||||
|
||||
byte[] expected = File.ReadAllBytes(snapshotPath);
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
/// Provides HTTP canning/mocking capabilities for connector tests.
|
||||
/// Use this for fixture-based testing of external data source connectors.
|
||||
/// </summary>
|
||||
public sealed class ConnectorHttpFixture : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, HttpResponseEntry> _responses = new();
|
||||
private readonly List<HttpRequestMessage> _capturedRequests = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of all captured requests for verification.
|
||||
/// </summary>
|
||||
public IReadOnlyList<HttpRequestMessage> CapturedRequests => _capturedRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the HttpClient configured with canned responses.
|
||||
/// </summary>
|
||||
public HttpClient CreateClient()
|
||||
{
|
||||
return new HttpClient(new CannedMessageHandler(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the HttpMessageHandler for DI scenarios.
|
||||
/// </summary>
|
||||
public HttpMessageHandler CreateHandler()
|
||||
{
|
||||
return new CannedMessageHandler(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a JSON response for a URL pattern.
|
||||
/// </summary>
|
||||
public void AddJsonResponse(string urlPattern, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
_responses[urlPattern] = new HttpResponseEntry(
|
||||
statusCode,
|
||||
"application/json",
|
||||
Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a JSON response from a fixture file.
|
||||
/// </summary>
|
||||
public void AddJsonResponseFromFile(string urlPattern, string fixturePath, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var json = File.ReadAllText(fixturePath);
|
||||
AddJsonResponse(urlPattern, json, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a raw bytes response for a URL pattern.
|
||||
/// </summary>
|
||||
public void AddBinaryResponse(string urlPattern, byte[] content, string contentType, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
_responses[urlPattern] = new HttpResponseEntry(statusCode, contentType, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a gzipped JSON response for testing decompression.
|
||||
/// </summary>
|
||||
public void AddGzipJsonResponse(string urlPattern, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionMode.Compress))
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
gzip.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
_responses[urlPattern] = new HttpResponseEntry(statusCode, "application/json", output.ToArray(), "gzip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an error response for a URL pattern.
|
||||
/// </summary>
|
||||
public void AddErrorResponse(string urlPattern, HttpStatusCode statusCode, string? errorBody = null)
|
||||
{
|
||||
_responses[urlPattern] = new HttpResponseEntry(
|
||||
statusCode,
|
||||
"application/json",
|
||||
errorBody != null ? Encoding.UTF8.GetBytes(errorBody) : Array.Empty<byte>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a timeout/exception for a URL pattern.
|
||||
/// </summary>
|
||||
public void AddTimeout(string urlPattern)
|
||||
{
|
||||
_responses[urlPattern] = new HttpResponseEntry(IsTimeout: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all canned responses and captured requests.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_responses.Clear();
|
||||
_capturedRequests.Clear();
|
||||
}
|
||||
|
||||
internal HttpResponseMessage? GetResponse(HttpRequestMessage request)
|
||||
{
|
||||
_capturedRequests.Add(request);
|
||||
var url = request.RequestUri?.ToString() ?? "";
|
||||
|
||||
foreach (var (pattern, entry) in _responses)
|
||||
{
|
||||
if (MatchesPattern(url, pattern))
|
||||
{
|
||||
if (entry.IsTimeout)
|
||||
{
|
||||
throw new TaskCanceledException("Request timed out (simulated)");
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(entry.StatusCode)
|
||||
{
|
||||
Content = new ByteArrayContent(entry.Content)
|
||||
};
|
||||
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(entry.ContentType);
|
||||
|
||||
if (entry.ContentEncoding != null)
|
||||
{
|
||||
response.Content.Headers.ContentEncoding.Add(entry.ContentEncoding);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Return 404 for unmatched URLs
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No canned response for: {url}")
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string url, string pattern)
|
||||
{
|
||||
// Exact match
|
||||
if (url == pattern) return true;
|
||||
|
||||
// Wildcard support: pattern ends with *
|
||||
if (pattern.EndsWith('*') && url.StartsWith(pattern[..^1])) return true;
|
||||
|
||||
// Contains support: pattern is surrounded by *
|
||||
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
|
||||
{
|
||||
var inner = pattern[1..^1];
|
||||
return url.Contains(inner);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_responses.Clear();
|
||||
_capturedRequests.Clear();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed record HttpResponseEntry(
|
||||
HttpStatusCode StatusCode = HttpStatusCode.OK,
|
||||
string ContentType = "application/json",
|
||||
byte[]? Content = null,
|
||||
string? ContentEncoding = null,
|
||||
bool IsTimeout = false)
|
||||
{
|
||||
public byte[] Content { get; } = Content ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
private sealed class CannedMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly ConnectorHttpFixture _fixture;
|
||||
|
||||
public CannedMessageHandler(ConnectorHttpFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = _fixture.GetResponse(request);
|
||||
return Task.FromResult(response ?? new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for connector resilience tests.
|
||||
/// Tests handling of partial/bad input and deterministic failure classification.
|
||||
/// </summary>
|
||||
public abstract class ConnectorResilienceTestBase : IDisposable
|
||||
{
|
||||
protected readonly ConnectorHttpFixture HttpFixture;
|
||||
private bool _disposed;
|
||||
|
||||
protected ConnectorResilienceTestBase()
|
||||
{
|
||||
HttpFixture = new ConnectorHttpFixture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base directory for test fixtures.
|
||||
/// </summary>
|
||||
protected abstract string FixturesDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse JSON and returns whether it succeeded.
|
||||
/// </summary>
|
||||
protected abstract (bool Success, string? ErrorCategory) TryParse(string json);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to fetch from URL and returns whether it succeeded.
|
||||
/// </summary>
|
||||
protected abstract Task<(bool Success, string? ErrorCategory)> TryFetchAsync(string url, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fixture file.
|
||||
/// </summary>
|
||||
protected string ReadFixture(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesDirectory, fileName);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRequiredFields_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
// This test should be overridden per connector to test specific required fields
|
||||
var invalidJson = "{}";
|
||||
|
||||
var results = new List<string?>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var (success, errorCategory) = TryParse(invalidJson);
|
||||
results.Add(errorCategory);
|
||||
}
|
||||
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
"error category should be deterministic for same input");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedJson_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
var malformedJson = "{ invalid json }";
|
||||
|
||||
var results = new List<string?>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var (success, errorCategory) = TryParse(malformedJson);
|
||||
success.Should().BeFalse("malformed JSON should fail to parse");
|
||||
results.Add(errorCategory);
|
||||
}
|
||||
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
"error category should be deterministic for malformed JSON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyInput_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
var emptyJson = "";
|
||||
|
||||
var results = new List<string?>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var (success, errorCategory) = TryParse(emptyJson);
|
||||
results.Add(errorCategory);
|
||||
}
|
||||
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
"error category should be deterministic for empty input");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullInput_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
var (success, errorCategory) = TryParse(null!);
|
||||
success.Should().BeFalse("null input should fail to parse");
|
||||
errorCategory.Should().NotBeNullOrEmpty("should have error category");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpError_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
HttpFixture.AddErrorResponse("https://test.example.com/*", System.Net.HttpStatusCode.InternalServerError);
|
||||
|
||||
var results = new List<string?>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
|
||||
success.Should().BeFalse("HTTP 500 should fail");
|
||||
results.Add(errorCategory);
|
||||
}
|
||||
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
"error category should be deterministic for HTTP errors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpNotFound_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
HttpFixture.AddErrorResponse("https://test.example.com/*", System.Net.HttpStatusCode.NotFound);
|
||||
|
||||
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
|
||||
|
||||
success.Should().BeFalse("HTTP 404 should fail");
|
||||
errorCategory.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_ProducesDeterministicErrorCategory()
|
||||
{
|
||||
HttpFixture.AddTimeout("https://test.example.com/*");
|
||||
|
||||
var results = new List<string?>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (success, errorCategory) = await TryFetchAsync("https://test.example.com/api");
|
||||
success.Should().BeFalse("timeout should fail");
|
||||
results.Add(errorCategory);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
results.Add("timeout");
|
||||
}
|
||||
}
|
||||
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
"error category should be deterministic for timeouts");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
HttpFixture.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for connector security tests.
|
||||
/// Tests URL allowlist, redirect handling, max payload size, decompression bombs.
|
||||
/// </summary>
|
||||
public abstract class ConnectorSecurityTestBase : IDisposable
|
||||
{
|
||||
protected readonly ConnectorHttpFixture HttpFixture;
|
||||
private bool _disposed;
|
||||
|
||||
protected ConnectorSecurityTestBase()
|
||||
{
|
||||
HttpFixture = new ConnectorHttpFixture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to fetch from URL and returns whether it was allowed.
|
||||
/// </summary>
|
||||
protected abstract Task<bool> IsUrlAllowedAsync(string url, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum allowed payload size in bytes.
|
||||
/// </summary>
|
||||
protected abstract long MaxPayloadSizeBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of allowed URL patterns/domains.
|
||||
/// </summary>
|
||||
protected abstract IReadOnlyList<string> AllowedUrlPatterns { get; }
|
||||
|
||||
[Fact]
|
||||
public async Task AllowlistedUrl_IsAccepted()
|
||||
{
|
||||
foreach (var pattern in AllowedUrlPatterns)
|
||||
{
|
||||
var url = pattern.Replace("*", "test");
|
||||
HttpFixture.AddJsonResponse(url, "{}");
|
||||
|
||||
var allowed = await IsUrlAllowedAsync(url);
|
||||
allowed.Should().BeTrue($"URL '{url}' should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonAllowlistedUrl_IsRejected()
|
||||
{
|
||||
var disallowedUrls = new[]
|
||||
{
|
||||
"https://evil.example.com/api",
|
||||
"http://malicious.test/data",
|
||||
"file:///etc/passwd",
|
||||
"data:text/html,<script>alert(1)</script>"
|
||||
};
|
||||
|
||||
foreach (var url in disallowedUrls)
|
||||
{
|
||||
HttpFixture.AddJsonResponse(url, "{}");
|
||||
|
||||
var allowed = await IsUrlAllowedAsync(url);
|
||||
allowed.Should().BeFalse($"URL '{url}' should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedPayload_IsRejected()
|
||||
{
|
||||
// Create payload larger than max
|
||||
var largePayload = new string('x', (int)MaxPayloadSizeBytes + 1000);
|
||||
HttpFixture.AddJsonResponse("https://test.example.com/*", $"{{\"data\":\"{largePayload}\"}}");
|
||||
|
||||
Func<Task> act = async () => await IsUrlAllowedAsync("https://test.example.com/api");
|
||||
|
||||
// Should either return false or throw
|
||||
// Implementation-specific behavior
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecompressionBomb_IsRejected()
|
||||
{
|
||||
// Create a small gzipped payload that expands to large size
|
||||
// This is a simplified test - real decompression bombs are more sophisticated
|
||||
var smallCompressed = "{}"; // In reality, this would be crafted maliciously
|
||||
HttpFixture.AddGzipJsonResponse("https://test.example.com/*", smallCompressed);
|
||||
|
||||
// The connector should detect and reject decompression bombs
|
||||
// Implementation varies by connector
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpsRedirectToHttp_IsRejected()
|
||||
{
|
||||
// Test that HTTPS -> HTTP downgrades are rejected
|
||||
// This requires redirect handling implementation
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
HttpFixture.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for connector parser tests.
|
||||
/// Inherit from this class to implement fixture-based parser testing.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRawModel">The raw upstream model type.</typeparam>
|
||||
/// <typeparam name="TNormalizedModel">The normalized internal model type.</typeparam>
|
||||
public abstract class ConnectorParserTestBase<TRawModel, TNormalizedModel> : IDisposable
|
||||
where TRawModel : class
|
||||
where TNormalizedModel : class
|
||||
{
|
||||
protected readonly ConnectorHttpFixture HttpFixture;
|
||||
private bool _disposed;
|
||||
|
||||
protected ConnectorParserTestBase()
|
||||
{
|
||||
HttpFixture = new ConnectorHttpFixture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base directory for test fixtures.
|
||||
/// </summary>
|
||||
protected abstract string FixturesDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory for expected snapshots.
|
||||
/// </summary>
|
||||
protected virtual string ExpectedDirectory => Path.Combine(FixturesDirectory, "..", "Expected");
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes raw upstream JSON to the raw model.
|
||||
/// </summary>
|
||||
protected abstract TRawModel DeserializeRaw(string json);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the raw model into the normalized model.
|
||||
/// </summary>
|
||||
protected abstract TNormalizedModel Parse(TRawModel raw);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the normalized model from JSON snapshot.
|
||||
/// </summary>
|
||||
protected abstract TNormalizedModel DeserializeNormalized(string json);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the normalized model to canonical JSON for comparison.
|
||||
/// </summary>
|
||||
protected virtual string SerializeToCanonical(TNormalizedModel model)
|
||||
{
|
||||
return CanonJson.Serialize(model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fixture file from the fixtures directory.
|
||||
/// </summary>
|
||||
protected string ReadFixture(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesDirectory, fileName);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an expected snapshot file.
|
||||
/// </summary>
|
||||
protected string ReadExpected(string fileName)
|
||||
{
|
||||
var path = Path.Combine(ExpectedDirectory, fileName);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a fixture parses to the expected canonical output.
|
||||
/// </summary>
|
||||
protected void VerifyParseSnapshot(string fixtureFile, string expectedFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = ReadFixture(fixtureFile);
|
||||
var expectedJson = ReadExpected(expectedFile);
|
||||
var raw = DeserializeRaw(rawJson);
|
||||
|
||||
// Act
|
||||
var normalized = Parse(raw);
|
||||
var actualJson = SerializeToCanonical(normalized);
|
||||
|
||||
// Assert
|
||||
actualJson.Should().Be(expectedJson,
|
||||
$"fixture '{fixtureFile}' should parse to expected '{expectedFile}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that parsing produces deterministic output.
|
||||
/// </summary>
|
||||
protected void VerifyDeterministicParse(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = ReadFixture(fixtureFile);
|
||||
|
||||
// Act
|
||||
var results = new List<string>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var raw = DeserializeRaw(rawJson);
|
||||
var normalized = Parse(raw);
|
||||
results.Add(SerializeToCanonical(normalized));
|
||||
}
|
||||
|
||||
// Assert
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
$"parsing '{fixtureFile}' multiple times should produce identical output");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates or creates an expected snapshot file.
|
||||
/// Use with STELLAOPS_UPDATE_FIXTURES=true environment variable.
|
||||
/// </summary>
|
||||
protected void UpdateSnapshot(string fixtureFile, string expectedFile)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") != "true")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Set STELLAOPS_UPDATE_FIXTURES=true to update snapshots");
|
||||
}
|
||||
|
||||
var rawJson = ReadFixture(fixtureFile);
|
||||
var raw = DeserializeRaw(rawJson);
|
||||
var normalized = Parse(raw);
|
||||
var canonicalJson = SerializeToCanonical(normalized);
|
||||
|
||||
var expectedPath = Path.Combine(ExpectedDirectory, expectedFile);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(expectedPath)!);
|
||||
File.WriteAllText(expectedPath, canonicalJson);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
HttpFixture.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for connector fetch + parse integration tests.
|
||||
/// </summary>
|
||||
/// <typeparam name="TConnector">The connector type.</typeparam>
|
||||
/// <typeparam name="TNormalizedModel">The normalized output type.</typeparam>
|
||||
public abstract class ConnectorFetchTestBase<TConnector, TNormalizedModel> : IDisposable
|
||||
where TConnector : class
|
||||
where TNormalizedModel : class
|
||||
{
|
||||
protected readonly ConnectorHttpFixture HttpFixture;
|
||||
private bool _disposed;
|
||||
|
||||
protected ConnectorFetchTestBase()
|
||||
{
|
||||
HttpFixture = new ConnectorHttpFixture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base directory for test fixtures.
|
||||
/// </summary>
|
||||
protected abstract string FixturesDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the connector instance configured with the HTTP fixture.
|
||||
/// </summary>
|
||||
protected abstract TConnector CreateConnector();
|
||||
|
||||
/// <summary>
|
||||
/// Executes the connector fetch operation.
|
||||
/// </summary>
|
||||
protected abstract Task<IReadOnlyList<TNormalizedModel>> FetchAsync(TConnector connector, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fixture file.
|
||||
/// </summary>
|
||||
protected string ReadFixture(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesDirectory, fileName);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a canned response from a fixture file.
|
||||
/// </summary>
|
||||
protected void SetupFixtureResponse(string urlPattern, string fixtureFile)
|
||||
{
|
||||
var json = ReadFixture(fixtureFile);
|
||||
HttpFixture.AddJsonResponse(urlPattern, json);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
HttpFixture.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
193
src/__Libraries/StellaOps.TestKit/Connectors/FixtureUpdater.cs
Normal file
193
src/__Libraries/StellaOps.TestKit/Connectors/FixtureUpdater.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for updating test fixtures from live sources.
|
||||
/// Enabled via STELLAOPS_UPDATE_FIXTURES=true environment variable.
|
||||
/// </summary>
|
||||
public sealed class FixtureUpdater
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _fixturesDirectory;
|
||||
private readonly bool _enabled;
|
||||
|
||||
public FixtureUpdater(string fixturesDirectory, HttpClient? httpClient = null)
|
||||
{
|
||||
_fixturesDirectory = fixturesDirectory;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_enabled = Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if fixture updating is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and saves a fixture from a live URL.
|
||||
/// Only runs when STELLAOPS_UPDATE_FIXTURES=true.
|
||||
/// </summary>
|
||||
public async Task UpdateFixtureFromUrlAsync(
|
||||
string url,
|
||||
string fixtureName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
await SaveFixtureAsync(fixtureName, content, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches JSON and saves as pretty-printed fixture.
|
||||
/// </summary>
|
||||
public async Task UpdateJsonFixtureFromUrlAsync(
|
||||
string url,
|
||||
string fixtureName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
// Pretty-print for readability
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var prettyJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
await SaveFixtureAsync(fixtureName, prettyJson, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves content to a fixture file.
|
||||
/// </summary>
|
||||
public async Task SaveFixtureAsync(
|
||||
string fixtureName,
|
||||
string content,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Path.Combine(_fixturesDirectory, fixtureName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, content, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a canonical JSON snapshot.
|
||||
/// </summary>
|
||||
public async Task SaveExpectedSnapshotAsync<T>(
|
||||
T model,
|
||||
string snapshotName,
|
||||
string? expectedDirectory = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Serialize(model);
|
||||
var directory = expectedDirectory ?? Path.Combine(_fixturesDirectory, "..", "Expected");
|
||||
var path = Path.Combine(directory, snapshotName);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, canonical, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares current live data with existing fixture and reports drift.
|
||||
/// </summary>
|
||||
public async Task<FixtureDriftReport> CheckDriftAsync(
|
||||
string url,
|
||||
string fixtureName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var fixturePath = Path.Combine(_fixturesDirectory, fixtureName);
|
||||
if (!File.Exists(fixturePath))
|
||||
{
|
||||
return new FixtureDriftReport(fixtureName, true, "Fixture file does not exist");
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new FixtureDriftReport(fixtureName, false, $"Failed to fetch: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var liveContent = await response.Content.ReadAsStringAsync(ct);
|
||||
var fixtureContent = await File.ReadAllTextAsync(fixturePath, ct);
|
||||
|
||||
// Try to normalize JSON for comparison
|
||||
try
|
||||
{
|
||||
var liveDoc = JsonDocument.Parse(liveContent);
|
||||
var fixtureDoc = JsonDocument.Parse(fixtureContent);
|
||||
|
||||
var liveNormalized = JsonSerializer.Serialize(liveDoc);
|
||||
var fixtureNormalized = JsonSerializer.Serialize(fixtureDoc);
|
||||
|
||||
if (liveNormalized != fixtureNormalized)
|
||||
{
|
||||
return new FixtureDriftReport(fixtureName, true, "JSON content differs", liveContent);
|
||||
}
|
||||
|
||||
return new FixtureDriftReport(fixtureName, false, "No drift detected");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Non-JSON content, compare raw
|
||||
if (liveContent != fixtureContent)
|
||||
{
|
||||
return new FixtureDriftReport(fixtureName, true, "Content differs", liveContent);
|
||||
}
|
||||
|
||||
return new FixtureDriftReport(fixtureName, false, "No drift detected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of schema/content drift between live source and fixture.
|
||||
/// </summary>
|
||||
public sealed record FixtureDriftReport(
|
||||
string FixtureName,
|
||||
bool HasDrift,
|
||||
string Message,
|
||||
string? LiveContent = null);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for fixture update operations.
|
||||
/// </summary>
|
||||
public sealed class FixtureUpdateConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapping of fixture names to live URLs.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> FixtureUrls { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Headers to include in live requests.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> RequestHeaders { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for live requests.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TestKit.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism gates for verifying reproducible outputs.
|
||||
/// Ensures that operations produce identical results across multiple executions.
|
||||
/// </summary>
|
||||
public static class DeterminismGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that a function produces identical output across multiple invocations.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation to test.</param>
|
||||
/// <param name="iterations">Number of times to execute (default: 3).</param>
|
||||
public static void AssertDeterministic(Func<string> operation, int iterations = 3)
|
||||
{
|
||||
if (iterations < 2)
|
||||
{
|
||||
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
|
||||
}
|
||||
|
||||
string? baseline = null;
|
||||
var results = new List<string>();
|
||||
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var result = operation();
|
||||
results.Add(result);
|
||||
|
||||
if (baseline == null)
|
||||
{
|
||||
baseline = result;
|
||||
}
|
||||
else if (result != baseline)
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Determinism violation detected at iteration {i + 1}.\n\n" +
|
||||
$"Baseline (iteration 1):\n{baseline}\n\n" +
|
||||
$"Different (iteration {i + 1}):\n{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a function produces identical binary output across multiple invocations.
|
||||
/// </summary>
|
||||
public static void AssertDeterministic(Func<byte[]> operation, int iterations = 3)
|
||||
{
|
||||
if (iterations < 2)
|
||||
{
|
||||
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
|
||||
}
|
||||
|
||||
byte[]? baseline = null;
|
||||
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var result = operation();
|
||||
|
||||
if (baseline == null)
|
||||
{
|
||||
baseline = result;
|
||||
}
|
||||
else if (!result.SequenceEqual(baseline))
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Binary determinism violation detected at iteration {i + 1}.\n" +
|
||||
$"Baseline hash: {ComputeHash(baseline)}\n" +
|
||||
$"Current hash: {ComputeHash(result)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a function producing JSON has stable property ordering and formatting.
|
||||
/// </summary>
|
||||
public static void AssertJsonDeterministic(Func<string> operation, int iterations = 3)
|
||||
{
|
||||
AssertDeterministic(() =>
|
||||
{
|
||||
var json = operation();
|
||||
// Canonicalize to detect property ordering issues
|
||||
return CanonicalizeJson(json);
|
||||
}, iterations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an object's JSON serialization is deterministic.
|
||||
/// </summary>
|
||||
public static void AssertJsonDeterministic<T>(Func<T> operation, int iterations = 3)
|
||||
{
|
||||
AssertDeterministic(() =>
|
||||
{
|
||||
var obj = operation();
|
||||
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null
|
||||
});
|
||||
return CanonicalizeJson(json);
|
||||
}, iterations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two objects produce identical canonical JSON.
|
||||
/// </summary>
|
||||
public static void AssertCanonicallyEqual(object expected, object actual)
|
||||
{
|
||||
var expectedJson = JsonSerializer.Serialize(expected);
|
||||
var actualJson = JsonSerializer.Serialize(actual);
|
||||
|
||||
var expectedCanonical = CanonicalizeJson(expectedJson);
|
||||
var actualCanonical = CanonicalizeJson(actualJson);
|
||||
|
||||
if (expectedCanonical != actualCanonical)
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Canonical JSON mismatch:\n\nExpected:\n{expectedCanonical}\n\nActual:\n{actualCanonical}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable SHA256 hash of text content.
|
||||
/// </summary>
|
||||
public static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return ComputeHash(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable SHA256 hash of binary content.
|
||||
/// </summary>
|
||||
public static string ComputeHash(byte[] content)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(content);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes JSON for comparison (stable property ordering, no whitespace).
|
||||
/// </summary>
|
||||
private static string CanonicalizeJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new DeterminismViolationException($"Failed to parse JSON for canonicalization: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that file paths are sorted deterministically (for SBOM manifests).
|
||||
/// </summary>
|
||||
public static void AssertSortedPaths(IEnumerable<string> paths)
|
||||
{
|
||||
var pathList = paths.ToList();
|
||||
var sortedPaths = pathList.OrderBy(p => p, StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!pathList.SequenceEqual(sortedPaths))
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Path ordering is non-deterministic.\n\n" +
|
||||
$"Actual order:\n{string.Join("\n", pathList.Take(10))}\n\n" +
|
||||
$"Expected (sorted) order:\n{string.Join("\n", sortedPaths.Take(10))}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that timestamps are in UTC and ISO 8601 format.
|
||||
/// </summary>
|
||||
public static void AssertUtcIso8601(string timestamp)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(timestamp, out var dto))
|
||||
{
|
||||
throw new DeterminismViolationException($"Invalid timestamp format: {timestamp}");
|
||||
}
|
||||
|
||||
if (dto.Offset != TimeSpan.Zero)
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Timestamp is not UTC: {timestamp} (offset: {dto.Offset})");
|
||||
}
|
||||
|
||||
// Verify ISO 8601 format with 'Z' suffix
|
||||
var iso8601 = dto.ToString("o");
|
||||
if (!iso8601.EndsWith("Z"))
|
||||
{
|
||||
throw new DeterminismViolationException(
|
||||
$"Timestamp does not have 'Z' suffix: {timestamp}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when determinism violations are detected.
|
||||
/// </summary>
|
||||
public sealed class DeterminismViolationException : Exception
|
||||
{
|
||||
public DeterminismViolationException(string message) : base(message) { }
|
||||
public DeterminismViolationException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace StellaOps.TestKit.Deterministic;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic random number generation for testing.
|
||||
/// Uses a fixed seed to ensure reproducible random sequences.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// var random = new DeterministicRandom(seed: 42);
|
||||
/// var value1 = random.Next(); // Same value every time with seed 42
|
||||
/// var value2 = random.NextDouble(); // Deterministic sequence
|
||||
///
|
||||
/// // For property-based testing with FsCheck
|
||||
/// var gen = DeterministicRandom.CreateGen(seed: 42);
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class DeterministicRandom
|
||||
{
|
||||
private readonly System.Random _random;
|
||||
private readonly int _seed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new deterministic random number generator with the specified seed.
|
||||
/// </summary>
|
||||
/// <param name="seed">The seed value. Same seed always produces same sequence.</param>
|
||||
public DeterministicRandom(int seed)
|
||||
{
|
||||
_seed = seed;
|
||||
_random = new System.Random(seed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seed used for this random number generator.
|
||||
/// </summary>
|
||||
public int Seed => _seed;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a non-negative random integer.
|
||||
/// </summary>
|
||||
public int Next() => _random.Next();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a non-negative random integer less than the specified maximum.
|
||||
/// </summary>
|
||||
public int Next(int maxValue) => _random.Next(maxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random integer within the specified range.
|
||||
/// </summary>
|
||||
public int Next(int minValue, int maxValue) => _random.Next(minValue, maxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random floating-point number between 0.0 and 1.0.
|
||||
/// </summary>
|
||||
public double NextDouble() => _random.NextDouble();
|
||||
|
||||
/// <summary>
|
||||
/// Fills the elements of the specified array with random bytes.
|
||||
/// </summary>
|
||||
public void NextBytes(byte[] buffer) => _random.NextBytes(buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Fills the elements of the specified span with random bytes.
|
||||
/// </summary>
|
||||
public void NextBytes(Span<byte> buffer) => _random.NextBytes(buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new deterministic Random instance with the specified seed.
|
||||
/// Useful for integration with code that expects System.Random.
|
||||
/// </summary>
|
||||
public static System.Random CreateRandom(int seed) => new(seed);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic GUID based on the seed.
|
||||
/// </summary>
|
||||
public Guid NextGuid()
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
_random.NextBytes(bytes);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic string of the specified length using alphanumeric characters.
|
||||
/// </summary>
|
||||
public string NextString(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = chars[_random.Next(chars.Length)];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a random element from the specified array.
|
||||
/// </summary>
|
||||
public T NextElement<T>(T[] array)
|
||||
{
|
||||
if (array == null || array.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Array cannot be null or empty", nameof(array));
|
||||
}
|
||||
return array[_random.Next(array.Length)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuffles an array in-place using the Fisher-Yates algorithm (deterministic).
|
||||
/// </summary>
|
||||
public void Shuffle<T>(T[] array)
|
||||
{
|
||||
if (array == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(array));
|
||||
}
|
||||
|
||||
for (int i = array.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = _random.Next(i + 1);
|
||||
(array[i], array[j]) = (array[j], array[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace StellaOps.TestKit.Deterministic;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic time for testing. Replaces DateTime.UtcNow and DateTimeOffset.UtcNow
|
||||
/// to ensure reproducible test results.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// using var deterministicTime = new DeterministicTime(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
/// // All calls to deterministicTime.UtcNow return the fixed time
|
||||
/// var timestamp = deterministicTime.UtcNow; // Always 2026-01-15T10:30:00Z
|
||||
///
|
||||
/// // Advance time by a specific duration
|
||||
/// deterministicTime.Advance(TimeSpan.FromHours(2));
|
||||
/// var laterTimestamp = deterministicTime.UtcNow; // 2026-01-15T12:30:00Z
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class DeterministicTime : IDisposable
|
||||
{
|
||||
private DateTime _currentUtc;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new deterministic time provider starting at the specified UTC time.
|
||||
/// </summary>
|
||||
/// <param name="startUtc">The starting UTC time. Must have DateTimeKind.Utc.</param>
|
||||
/// <exception cref="ArgumentException">Thrown if startUtc is not UTC.</exception>
|
||||
public DeterministicTime(DateTime startUtc)
|
||||
{
|
||||
if (startUtc.Kind != DateTimeKind.Utc)
|
||||
{
|
||||
throw new ArgumentException("Start time must be UTC", nameof(startUtc));
|
||||
}
|
||||
|
||||
_currentUtc = startUtc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current deterministic UTC time.
|
||||
/// </summary>
|
||||
public DateTime UtcNow
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _currentUtc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current deterministic UTC time as DateTimeOffset.
|
||||
/// </summary>
|
||||
public DateTimeOffset UtcNowOffset => new(_currentUtc, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Advances the deterministic time by the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration to advance. Can be negative to go backwards.</param>
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentUtc = _currentUtc.Add(duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the deterministic time to a specific UTC value.
|
||||
/// </summary>
|
||||
/// <param name="newUtc">The new UTC time. Must have DateTimeKind.Utc.</param>
|
||||
/// <exception cref="ArgumentException">Thrown if newUtc is not UTC.</exception>
|
||||
public void SetTo(DateTime newUtc)
|
||||
{
|
||||
if (newUtc.Kind != DateTimeKind.Utc)
|
||||
{
|
||||
throw new ArgumentException("Time must be UTC", nameof(newUtc));
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_currentUtc = newUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the deterministic time to the starting value.
|
||||
/// </summary>
|
||||
public void Reset(DateTime startUtc)
|
||||
{
|
||||
if (startUtc.Kind != DateTimeKind.Utc)
|
||||
{
|
||||
throw new ArgumentException("Start time must be UTC", nameof(startUtc));
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_currentUtc = startUtc;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.TestKit.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for HttpClient to support test scenarios.
|
||||
/// </summary>
|
||||
public static class HttpClientTestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a request without any authentication headers.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithoutAuthAsync(
|
||||
this HttpClient client,
|
||||
HttpMethod method,
|
||||
string endpoint,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, endpoint);
|
||||
request.Headers.Authorization = null; // Ensure no auth header
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with an expired bearer token.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithExpiredTokenAsync(
|
||||
this HttpClient client,
|
||||
string endpoint,
|
||||
string expiredToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with a malformed content type (text/plain instead of application/json).
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithMalformedContentTypeAsync(
|
||||
this HttpClient client,
|
||||
HttpMethod method,
|
||||
string endpoint,
|
||||
string body,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, endpoint)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/plain")
|
||||
};
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with an oversized payload.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendOversizedPayloadAsync(
|
||||
this HttpClient client,
|
||||
string endpoint,
|
||||
int sizeBytes,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new string('x', sizeBytes);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with the wrong HTTP method (opposite of expected).
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithWrongMethodAsync(
|
||||
this HttpClient client,
|
||||
string endpoint,
|
||||
HttpMethod expectedMethod,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// If endpoint expects POST, send GET; if expects GET, send DELETE
|
||||
var wrongMethod = expectedMethod == HttpMethod.Post ? HttpMethod.Get :
|
||||
expectedMethod == HttpMethod.Get ? HttpMethod.Delete :
|
||||
expectedMethod == HttpMethod.Put ? HttpMethod.Patch :
|
||||
expectedMethod == HttpMethod.Delete ? HttpMethod.Post :
|
||||
HttpMethod.Options;
|
||||
|
||||
var request = new HttpRequestMessage(wrongMethod, endpoint);
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with a bearer token for a specific tenant.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithTokenAsync(
|
||||
this HttpClient client,
|
||||
HttpMethod method,
|
||||
string endpoint,
|
||||
string token,
|
||||
HttpContent? content = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, endpoint)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return await client.SendAsync(request, ct);
|
||||
}
|
||||
}
|
||||
200
src/__Libraries/StellaOps.TestKit/Fixtures/ContractTestHelper.cs
Normal file
200
src/__Libraries/StellaOps.TestKit/Fixtures/ContractTestHelper.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for API contract testing using OpenAPI schema snapshots.
|
||||
/// </summary>
|
||||
public static class ContractTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches and validates the OpenAPI schema against a snapshot.
|
||||
/// </summary>
|
||||
public static async Task ValidateOpenApiSchemaAsync<TProgram>(
|
||||
WebApplicationFactory<TProgram> factory,
|
||||
string expectedSnapshotPath,
|
||||
string swaggerEndpoint = "/swagger/v1/swagger.json")
|
||||
where TProgram : class
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(swaggerEndpoint);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var actualSchema = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (ShouldUpdateSnapshots())
|
||||
{
|
||||
await UpdateSnapshotAsync(expectedSnapshotPath, actualSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedSchema = await File.ReadAllTextAsync(expectedSnapshotPath);
|
||||
|
||||
// Normalize both for comparison
|
||||
var actualNormalized = NormalizeOpenApiSchema(actualSchema);
|
||||
var expectedNormalized = NormalizeOpenApiSchema(expectedSchema);
|
||||
|
||||
actualNormalized.Should().Be(expectedNormalized,
|
||||
"OpenAPI schema should match snapshot. Set STELLAOPS_UPDATE_FIXTURES=true to update.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the schema contains expected endpoints.
|
||||
/// </summary>
|
||||
public static async Task ValidateEndpointsExistAsync<TProgram>(
|
||||
WebApplicationFactory<TProgram> factory,
|
||||
IEnumerable<string> expectedEndpoints,
|
||||
string swaggerEndpoint = "/swagger/v1/swagger.json")
|
||||
where TProgram : class
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(swaggerEndpoint);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var schemaJson = await response.Content.ReadAsStringAsync();
|
||||
var schema = JsonDocument.Parse(schemaJson);
|
||||
var paths = schema.RootElement.GetProperty("paths");
|
||||
|
||||
foreach (var endpoint in expectedEndpoints)
|
||||
{
|
||||
paths.TryGetProperty(endpoint, out _).Should().BeTrue(
|
||||
$"Expected endpoint '{endpoint}' should exist in OpenAPI schema");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects breaking changes between current schema and snapshot.
|
||||
/// </summary>
|
||||
public static async Task<SchemaBreakingChanges> DetectBreakingChangesAsync<TProgram>(
|
||||
WebApplicationFactory<TProgram> factory,
|
||||
string snapshotPath,
|
||||
string swaggerEndpoint = "/swagger/v1/swagger.json")
|
||||
where TProgram : class
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(swaggerEndpoint);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var actualSchema = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!File.Exists(snapshotPath))
|
||||
{
|
||||
return new SchemaBreakingChanges(new List<string> { "No previous snapshot exists" }, new List<string>());
|
||||
}
|
||||
|
||||
var expectedSchema = await File.ReadAllTextAsync(snapshotPath);
|
||||
|
||||
return CompareSchemas(expectedSchema, actualSchema);
|
||||
}
|
||||
|
||||
private static SchemaBreakingChanges CompareSchemas(string expected, string actual)
|
||||
{
|
||||
var breakingChanges = new List<string>();
|
||||
var nonBreakingChanges = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var expectedDoc = JsonDocument.Parse(expected);
|
||||
var actualDoc = JsonDocument.Parse(actual);
|
||||
|
||||
// Check for removed endpoints (breaking)
|
||||
if (expectedDoc.RootElement.TryGetProperty("paths", out var expectedPaths) &&
|
||||
actualDoc.RootElement.TryGetProperty("paths", out var actualPaths))
|
||||
{
|
||||
foreach (var path in expectedPaths.EnumerateObject())
|
||||
{
|
||||
if (!actualPaths.TryGetProperty(path.Name, out _))
|
||||
{
|
||||
breakingChanges.Add($"Endpoint removed: {path.Name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for removed methods
|
||||
foreach (var method in path.Value.EnumerateObject())
|
||||
{
|
||||
if (!actualPaths.GetProperty(path.Name).TryGetProperty(method.Name, out _))
|
||||
{
|
||||
breakingChanges.Add($"Method removed: {method.Name.ToUpper()} {path.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new endpoints (non-breaking)
|
||||
foreach (var path in actualPaths.EnumerateObject())
|
||||
{
|
||||
if (!expectedPaths.TryGetProperty(path.Name, out _))
|
||||
{
|
||||
nonBreakingChanges.Add($"Endpoint added: {path.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed schemas (breaking)
|
||||
if (expectedDoc.RootElement.TryGetProperty("components", out var expectedComponents) &&
|
||||
expectedComponents.TryGetProperty("schemas", out var expectedSchemas) &&
|
||||
actualDoc.RootElement.TryGetProperty("components", out var actualComponents) &&
|
||||
actualComponents.TryGetProperty("schemas", out var actualSchemas))
|
||||
{
|
||||
foreach (var schema in expectedSchemas.EnumerateObject())
|
||||
{
|
||||
if (!actualSchemas.TryGetProperty(schema.Name, out _))
|
||||
{
|
||||
breakingChanges.Add($"Schema removed: {schema.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
breakingChanges.Add($"Schema parse error: {ex.Message}");
|
||||
}
|
||||
|
||||
return new SchemaBreakingChanges(breakingChanges, nonBreakingChanges);
|
||||
}
|
||||
|
||||
private static string NormalizeOpenApiSchema(string schema)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(schema);
|
||||
// Remove non-deterministic fields
|
||||
return JsonSerializer.Serialize(doc, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldUpdateSnapshots()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
|
||||
}
|
||||
|
||||
private static async Task UpdateSnapshotAsync(string path, string content)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
// Pretty-print for readability
|
||||
var doc = JsonDocument.Parse(content);
|
||||
var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(path, pretty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of schema breaking change detection.
|
||||
/// </summary>
|
||||
public sealed record SchemaBreakingChanges(
|
||||
IReadOnlyList<string> BreakingChanges,
|
||||
IReadOnlyList<string> NonBreakingChanges)
|
||||
{
|
||||
public bool HasBreakingChanges => BreakingChanges.Count > 0;
|
||||
}
|
||||
152
src/__Libraries/StellaOps.TestKit/Fixtures/HttpFixtureServer.cs
Normal file
152
src/__Libraries/StellaOps.TestKit/Fixtures/HttpFixtureServer.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory HTTP test server using WebApplicationFactory for contract testing.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProgram">The entry point type of the web application (usually Program).</typeparam>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// public class ApiTests : IClassFixture<HttpFixtureServer<Program>>
|
||||
/// {
|
||||
/// private readonly HttpClient _client;
|
||||
///
|
||||
/// public ApiTests(HttpFixtureServer<Program> fixture)
|
||||
/// {
|
||||
/// _client = fixture.CreateClient();
|
||||
/// }
|
||||
///
|
||||
/// [Fact]
|
||||
/// public async Task GetHealth_ReturnsOk()
|
||||
/// {
|
||||
/// var response = await _client.GetAsync("/health");
|
||||
/// response.EnsureSuccessStatusCode();
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram>
|
||||
where TProgram : class
|
||||
{
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP fixture server with optional service configuration.
|
||||
/// </summary>
|
||||
/// <param name="configureServices">Optional action to configure test services (e.g., replace dependencies with mocks).</param>
|
||||
public HttpFixtureServer(Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
_configureServices = configureServices;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the web host for testing (disables HTTPS redirection, applies custom services).
|
||||
/// </summary>
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Apply user-provided service configuration (e.g., mock dependencies)
|
||||
_configureServices?.Invoke(services);
|
||||
});
|
||||
|
||||
builder.UseEnvironment("Test");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient configured to communicate with the test server.
|
||||
/// </summary>
|
||||
public new HttpClient CreateClient()
|
||||
{
|
||||
return base.CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with custom configuration.
|
||||
/// </summary>
|
||||
public HttpClient CreateClient(Action<HttpClient> configure)
|
||||
{
|
||||
var client = CreateClient();
|
||||
configure(client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a stub HTTP message handler for hermetic HTTP tests without external dependencies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// var handler = new HttpMessageHandlerStub()
|
||||
/// .WhenRequest("https://api.example.com/data")
|
||||
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
|
||||
///
|
||||
/// var httpClient = new HttpClient(handler);
|
||||
/// var response = await httpClient.GetAsync("https://api.example.com/data");
|
||||
/// // response.StatusCode == HttpStatusCode.OK
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, Func<HttpRequestMessage, Task<HttpResponseMessage>>> _handlers = new();
|
||||
private Func<HttpRequestMessage, Task<HttpResponseMessage>>? _defaultHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Configures a response for a specific URL.
|
||||
/// </summary>
|
||||
public HttpMessageHandlerStub WhenRequest(string url, Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
|
||||
{
|
||||
_handlers[url] = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a simple response for a specific URL.
|
||||
/// </summary>
|
||||
public HttpMessageHandlerStub WhenRequest(string url, HttpStatusCode statusCode, string? content = null)
|
||||
{
|
||||
return WhenRequest(url, _ => Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = content != null ? new StringContent(content) : null
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a default handler for unmatched requests.
|
||||
/// </summary>
|
||||
public HttpMessageHandlerStub WhenAnyRequest(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
|
||||
{
|
||||
_defaultHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the HTTP request through the stub handler.
|
||||
/// </summary>
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? string.Empty;
|
||||
|
||||
if (_handlers.TryGetValue(url, out var handler))
|
||||
{
|
||||
return await handler(request);
|
||||
}
|
||||
|
||||
if (_defaultHandler != null)
|
||||
{
|
||||
return await _defaultHandler(request);
|
||||
}
|
||||
|
||||
// Default: 404 Not Found for unmatched requests
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No stub configured for {url}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,38 @@
|
||||
using System.Reflection;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Isolation modes for PostgreSQL test fixtures.
|
||||
/// </summary>
|
||||
public enum PostgresIsolationMode
|
||||
{
|
||||
/// <summary>Each test gets its own schema. Default, most isolated.</summary>
|
||||
SchemaPerTest,
|
||||
/// <summary>Truncate all tables between tests. Faster but shared schema.</summary>
|
||||
Truncation,
|
||||
/// <summary>Each test gets its own database. Maximum isolation, slowest.</summary>
|
||||
DatabasePerTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a migration source for PostgreSQL fixtures.
|
||||
/// </summary>
|
||||
public sealed record MigrationSource(string Module, string ScriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for PostgreSQL database using Testcontainers.
|
||||
/// Provides an isolated PostgreSQL instance for integration tests.
|
||||
/// Provides an isolated PostgreSQL instance for integration tests with
|
||||
/// configurable isolation modes and migration support.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private readonly List<MigrationSource> _migrations = new();
|
||||
private int _schemaCounter;
|
||||
private int _databaseCounter;
|
||||
|
||||
public PostgresFixture()
|
||||
{
|
||||
@@ -21,6 +44,11 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the isolation mode for tests.
|
||||
/// </summary>
|
||||
public PostgresIsolationMode IsolationMode { get; set; } = PostgresIsolationMode.SchemaPerTest;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL container.
|
||||
/// </summary>
|
||||
@@ -51,6 +79,163 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers migrations to be applied for a module.
|
||||
/// </summary>
|
||||
public void RegisterMigrations(string module, string scriptPath)
|
||||
{
|
||||
_migrations.Add(new MigrationSource(module, scriptPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test session with appropriate isolation.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateSessionAsync(string? testName = null)
|
||||
{
|
||||
return IsolationMode switch
|
||||
{
|
||||
PostgresIsolationMode.SchemaPerTest => await CreateSchemaSessionAsync(testName),
|
||||
PostgresIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
|
||||
PostgresIsolationMode.Truncation => new PostgresTestSession(ConnectionString, "public", this),
|
||||
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a schema-isolated session for a test.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateSchemaSessionAsync(string? testName = null)
|
||||
{
|
||||
var schemaName = $"test_{Interlocked.Increment(ref _schemaCounter):D4}_{testName ?? "anon"}";
|
||||
|
||||
await ExecuteSqlAsync($"CREATE SCHEMA IF NOT EXISTS \"{schemaName}\"");
|
||||
|
||||
// Apply migrations to the new schema
|
||||
await ApplyMigrationsAsync(schemaName);
|
||||
|
||||
var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(ConnectionString)
|
||||
{
|
||||
SearchPath = schemaName
|
||||
}.ToString();
|
||||
|
||||
return new PostgresTestSession(connectionString, schemaName, this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a database-isolated session for a test.
|
||||
/// </summary>
|
||||
public async Task<PostgresTestSession> CreateDatabaseSessionAsync(string? testName = null)
|
||||
{
|
||||
var dbName = $"test_{Interlocked.Increment(ref _databaseCounter):D4}_{testName ?? "anon"}";
|
||||
|
||||
await CreateDatabaseAsync(dbName);
|
||||
|
||||
var connectionString = GetConnectionString(dbName);
|
||||
|
||||
// Apply migrations to the new database
|
||||
await ApplyMigrationsToDatabaseAsync(connectionString);
|
||||
|
||||
return new PostgresTestSession(connectionString, "public", this, dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all user tables in the public schema.
|
||||
/// </summary>
|
||||
public async Task TruncateAllTablesAsync()
|
||||
{
|
||||
const string truncateSql = """
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public')
|
||||
LOOP
|
||||
EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""";
|
||||
await ExecuteSqlAsync(truncateSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies all registered migrations to a schema.
|
||||
/// </summary>
|
||||
public async Task ApplyMigrationsAsync(string schemaName)
|
||||
{
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (File.Exists(migration.ScriptPath))
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
|
||||
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
|
||||
await ExecuteSqlAsync(schemaQualifiedSql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations from an assembly's embedded resources to a schema.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded SQL migration resources.</param>
|
||||
/// <param name="schemaName">Target schema name.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
|
||||
public async Task ApplyMigrationsFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string schemaName,
|
||||
string? resourcePrefix = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(r => r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(r => string.IsNullOrEmpty(resourcePrefix) || r.Contains(resourcePrefix))
|
||||
.OrderBy(r => r)
|
||||
.ToList();
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync();
|
||||
|
||||
// Replace public schema with target schema
|
||||
var schemaQualifiedSql = sql.Replace("public.", $"\"{schemaName}\".");
|
||||
await ExecuteSqlAsync(schemaQualifiedSql);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations from an assembly's embedded resources using a marker type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
|
||||
/// <param name="schemaName">Target schema name.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
public Task ApplyMigrationsFromAssemblyAsync<TAssemblyMarker>(
|
||||
string schemaName,
|
||||
string? resourcePrefix = null)
|
||||
=> ApplyMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, schemaName, resourcePrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Applies all registered migrations to a database.
|
||||
/// </summary>
|
||||
private async Task ApplyMigrationsToDatabaseAsync(string connectionString)
|
||||
{
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (File.Exists(migration.ScriptPath))
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(migration.ScriptPath);
|
||||
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a SQL command against the database.
|
||||
/// </summary>
|
||||
@@ -68,7 +253,7 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
/// </summary>
|
||||
public async Task CreateDatabaseAsync(string databaseName)
|
||||
{
|
||||
var createDbSql = $"CREATE DATABASE {databaseName}";
|
||||
var createDbSql = $"CREATE DATABASE \"{databaseName}\"";
|
||||
await ExecuteSqlAsync(createDbSql);
|
||||
}
|
||||
|
||||
@@ -77,10 +262,19 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
/// </summary>
|
||||
public async Task DropDatabaseAsync(string databaseName)
|
||||
{
|
||||
var dropDbSql = $"DROP DATABASE IF EXISTS {databaseName}";
|
||||
var dropDbSql = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
|
||||
await ExecuteSqlAsync(dropDbSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops a schema within the database.
|
||||
/// </summary>
|
||||
public async Task DropSchemaAsync(string schemaName)
|
||||
{
|
||||
var dropSchemaSql = $"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE";
|
||||
await ExecuteSqlAsync(dropSchemaSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection string for a specific database in the container.
|
||||
/// </summary>
|
||||
@@ -94,6 +288,44 @@ public sealed class PostgresFixture : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an isolated test session within PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class PostgresTestSession : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresFixture _fixture;
|
||||
private readonly string? _databaseName;
|
||||
|
||||
public PostgresTestSession(string connectionString, string schema, PostgresFixture fixture, string? databaseName = null)
|
||||
{
|
||||
ConnectionString = connectionString;
|
||||
Schema = schema;
|
||||
_fixture = fixture;
|
||||
_databaseName = databaseName;
|
||||
}
|
||||
|
||||
/// <summary>Connection string for this session.</summary>
|
||||
public string ConnectionString { get; }
|
||||
|
||||
/// <summary>Schema name for this session.</summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the session resources.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_databaseName != null)
|
||||
{
|
||||
await _fixture.DropDatabaseAsync(_databaseName);
|
||||
}
|
||||
else if (Schema != "public")
|
||||
{
|
||||
await _fixture.DropSchemaAsync(Schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture for PostgreSQL to share the container across multiple test classes.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,56 +1,264 @@
|
||||
using Testcontainers.Redis;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using StackExchange.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for Valkey (Redis-compatible) using Testcontainers.
|
||||
/// Provides an isolated Valkey instance for integration tests.
|
||||
/// Isolation modes for Valkey/Redis test fixtures.
|
||||
/// </summary>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime
|
||||
public enum ValkeyIsolationMode
|
||||
{
|
||||
private readonly RedisContainer _container;
|
||||
/// <summary>Each test gets its own database (0-15). Default, good isolation.</summary>
|
||||
DatabasePerTest,
|
||||
/// <summary>Flush the current database between tests. Faster but shared.</summary>
|
||||
FlushDb,
|
||||
/// <summary>Flush all databases between tests. Maximum cleanup.</summary>
|
||||
FlushAll
|
||||
}
|
||||
|
||||
public ValkeyFixture()
|
||||
{
|
||||
_container = new RedisBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.Build();
|
||||
}
|
||||
/// <summary>
|
||||
/// Provides a Testcontainers-based Valkey (Redis-compatible) instance for integration tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage with xUnit:
|
||||
/// <code>
|
||||
/// public class MyTests : IClassFixture<ValkeyFixture>
|
||||
/// {
|
||||
/// private readonly ValkeyFixture _fixture;
|
||||
///
|
||||
/// public MyTests(ValkeyFixture fixture)
|
||||
/// {
|
||||
/// _fixture = fixture;
|
||||
/// }
|
||||
///
|
||||
/// [Fact]
|
||||
/// public async Task TestCache()
|
||||
/// {
|
||||
/// await using var session = await _fixture.CreateSessionAsync();
|
||||
/// await session.Database.StringSetAsync("key", "value");
|
||||
/// // ...
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private IContainer? _container;
|
||||
private ConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
private int _databaseCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the Valkey container.
|
||||
/// Gets the Redis/Valkey connection string (format: "host:port").
|
||||
/// </summary>
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
public string ConnectionString { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hostname of the Valkey container.
|
||||
/// Gets the Redis/Valkey host.
|
||||
/// </summary>
|
||||
public string Host => _container.Hostname;
|
||||
public string Host { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exposed port of the Valkey container.
|
||||
/// Gets the Redis/Valkey port.
|
||||
/// </summary>
|
||||
public ushort Port => _container.GetMappedPublicPort(6379);
|
||||
public int Port { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the isolation mode for tests.
|
||||
/// </summary>
|
||||
public ValkeyIsolationMode IsolationMode { get; set; } = ValkeyIsolationMode.DatabasePerTest;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying connection multiplexer.
|
||||
/// </summary>
|
||||
public ConnectionMultiplexer? Connection => _connection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the Valkey container asynchronously.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Use official Redis image (Valkey is Redis-compatible)
|
||||
// In production deployments, substitute with valkey/valkey image if needed
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("redis:7-alpine")
|
||||
.WithPortBinding(6379, true) // Bind to random host port
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
Host = _container.Hostname;
|
||||
Port = _container.GetMappedPublicPort(6379);
|
||||
ConnectionString = $"{Host}:{Port}";
|
||||
|
||||
_connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test session with appropriate isolation.
|
||||
/// </summary>
|
||||
public async Task<ValkeyTestSession> CreateSessionAsync(string? testName = null)
|
||||
{
|
||||
if (_connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
|
||||
}
|
||||
|
||||
return IsolationMode switch
|
||||
{
|
||||
ValkeyIsolationMode.DatabasePerTest => await CreateDatabaseSessionAsync(testName),
|
||||
ValkeyIsolationMode.FlushDb => await CreateFlushDbSessionAsync(),
|
||||
ValkeyIsolationMode.FlushAll => await CreateFlushAllSessionAsync(),
|
||||
_ => throw new InvalidOperationException($"Unknown isolation mode: {IsolationMode}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a database-isolated session (database 0-15).
|
||||
/// </summary>
|
||||
private async Task<ValkeyTestSession> CreateDatabaseSessionAsync(string? testName = null)
|
||||
{
|
||||
var dbIndex = Interlocked.Increment(ref _databaseCounter) % 16;
|
||||
var db = _connection!.GetDatabase(dbIndex);
|
||||
|
||||
// Flush this specific database before use
|
||||
var server = _connection.GetServer(ConnectionString);
|
||||
await server.FlushDatabaseAsync(dbIndex);
|
||||
|
||||
return new ValkeyTestSession(_connection, db, dbIndex, this, testName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a session that flushes the current database.
|
||||
/// </summary>
|
||||
private async Task<ValkeyTestSession> CreateFlushDbSessionAsync()
|
||||
{
|
||||
var db = _connection!.GetDatabase(0);
|
||||
var server = _connection.GetServer(ConnectionString);
|
||||
await server.FlushDatabaseAsync(0);
|
||||
|
||||
return new ValkeyTestSession(_connection, db, 0, this, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a session that flushes all databases.
|
||||
/// </summary>
|
||||
private async Task<ValkeyTestSession> CreateFlushAllSessionAsync()
|
||||
{
|
||||
var server = _connection!.GetServer(ConnectionString);
|
||||
await server.FlushAllDatabasesAsync();
|
||||
|
||||
var db = _connection.GetDatabase(0);
|
||||
return new ValkeyTestSession(_connection, db, 0, this, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes a specific database.
|
||||
/// </summary>
|
||||
public async Task FlushDatabaseAsync(int databaseIndex)
|
||||
{
|
||||
if (_connection == null) return;
|
||||
var server = _connection.GetServer(ConnectionString);
|
||||
await server.FlushDatabaseAsync(databaseIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all databases.
|
||||
/// </summary>
|
||||
public async Task FlushAllAsync()
|
||||
{
|
||||
if (_connection == null) return;
|
||||
var server = _connection.GetServer(ConnectionString);
|
||||
await server.FlushAllDatabasesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a database by index.
|
||||
/// </summary>
|
||||
public IDatabase GetDatabase(int dbIndex = 0)
|
||||
{
|
||||
if (_connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
|
||||
}
|
||||
return _connection.GetDatabase(dbIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the Valkey container asynchronously.
|
||||
/// </summary>
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
if (_connection != null)
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
if (_container != null)
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the fixture.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DisposeAsync().GetAwaiter().GetResult();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture for Valkey to share the container across multiple test classes.
|
||||
/// Represents an isolated test session within Valkey/Redis.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Valkey")]
|
||||
public class ValkeyCollection : ICollectionFixture<ValkeyFixture>
|
||||
public sealed class ValkeyTestSession : IAsyncDisposable
|
||||
{
|
||||
// This class has no code, and is never created. Its purpose is simply
|
||||
// to be the place to apply [CollectionDefinition] and all the
|
||||
// ICollectionFixture<> interfaces.
|
||||
private readonly ValkeyFixture _fixture;
|
||||
|
||||
public ValkeyTestSession(
|
||||
ConnectionMultiplexer connection,
|
||||
IDatabase database,
|
||||
int databaseIndex,
|
||||
ValkeyFixture fixture,
|
||||
string? testName)
|
||||
{
|
||||
Connection = connection;
|
||||
Database = database;
|
||||
DatabaseIndex = databaseIndex;
|
||||
_fixture = fixture;
|
||||
TestName = testName;
|
||||
}
|
||||
|
||||
/// <summary>The underlying connection multiplexer.</summary>
|
||||
public ConnectionMultiplexer Connection { get; }
|
||||
|
||||
/// <summary>The database for this session.</summary>
|
||||
public IDatabase Database { get; }
|
||||
|
||||
/// <summary>The database index (0-15).</summary>
|
||||
public int DatabaseIndex { get; }
|
||||
|
||||
/// <summary>Optional test name for debugging.</summary>
|
||||
public string? TestName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the session resources.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Flush this database on cleanup
|
||||
await _fixture.FlushDatabaseAsync(DatabaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
180
src/__Libraries/StellaOps.TestKit/Fixtures/WebServiceFixture.cs
Normal file
180
src/__Libraries/StellaOps.TestKit/Fixtures/WebServiceFixture.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for ASP.NET web services using WebApplicationFactory.
|
||||
/// Provides isolated service hosting with deterministic configuration.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProgram">The program entry point (typically Program class).</typeparam>
|
||||
public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsyncLifetime
|
||||
where TProgram : class
|
||||
{
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
private readonly Action<IWebHostBuilder>? _configureWebHost;
|
||||
|
||||
public WebServiceFixture(
|
||||
Action<IServiceCollection>? configureServices = null,
|
||||
Action<IWebHostBuilder>? configureWebHost = null)
|
||||
{
|
||||
_configureServices = configureServices;
|
||||
_configureWebHost = configureWebHost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the environment name for tests. Defaults to "Testing".
|
||||
/// </summary>
|
||||
protected virtual string EnvironmentName => "Testing";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment(EnvironmentName);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Add default test services
|
||||
services.AddSingleton<TestRequestContext>();
|
||||
|
||||
// Apply custom configuration
|
||||
_configureServices?.Invoke(services);
|
||||
});
|
||||
|
||||
_configureWebHost?.Invoke(builder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with optional authentication.
|
||||
/// </summary>
|
||||
public HttpClient CreateAuthenticatedClient(string? bearerToken = null)
|
||||
{
|
||||
var client = CreateClient();
|
||||
if (bearerToken != null)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with a specific tenant header.
|
||||
/// </summary>
|
||||
public HttpClient CreateTenantClient(string tenantId, string? bearerToken = null)
|
||||
{
|
||||
var client = CreateAuthenticatedClient(bearerToken);
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
|
||||
return client;
|
||||
}
|
||||
|
||||
public virtual Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides test request context for tracking.
|
||||
/// </summary>
|
||||
public sealed class TestRequestContext
|
||||
{
|
||||
private readonly List<RequestRecord> _requests = new();
|
||||
|
||||
public void RecordRequest(string method, string path, int statusCode)
|
||||
{
|
||||
lock (_requests)
|
||||
{
|
||||
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RequestRecord> GetRequests()
|
||||
{
|
||||
lock (_requests)
|
||||
{
|
||||
return _requests.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RequestRecord(string Method, string Path, int StatusCode, DateTime Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for web service testing.
|
||||
/// </summary>
|
||||
public static class WebServiceTestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a request with malformed content type header.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithMalformedContentTypeAsync(
|
||||
this HttpClient client,
|
||||
HttpMethod method,
|
||||
string url,
|
||||
string? body = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (body != null)
|
||||
{
|
||||
request.Content = new StringContent(body);
|
||||
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/malformed-type");
|
||||
}
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with oversized payload.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendOversizedPayloadAsync(
|
||||
this HttpClient client,
|
||||
string url,
|
||||
int sizeInBytes)
|
||||
{
|
||||
var payload = new string('x', sizeInBytes);
|
||||
var content = new StringContent($"{{\"data\":\"{payload}\"}}");
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
return await client.PostAsync(url, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with wrong HTTP method.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithWrongMethodAsync(
|
||||
this HttpClient client,
|
||||
string url,
|
||||
HttpMethod expectedMethod)
|
||||
{
|
||||
// If expected is POST, send GET; if expected is GET, send DELETE, etc.
|
||||
var wrongMethod = expectedMethod == HttpMethod.Get ? HttpMethod.Delete : HttpMethod.Get;
|
||||
return await client.SendAsync(new HttpRequestMessage(wrongMethod, url));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request without authentication.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithoutAuthAsync(
|
||||
this HttpClient client,
|
||||
HttpMethod method,
|
||||
string url)
|
||||
{
|
||||
// Remove any existing auth header
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
return await client.SendAsync(new HttpRequestMessage(method, url));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with expired token.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> SendWithExpiredTokenAsync(
|
||||
this HttpClient client,
|
||||
string url,
|
||||
string expiredToken)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken);
|
||||
return await client.GetAsync(url);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TestKit.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Assertion helpers for canonical JSON comparison in tests.
|
||||
/// Ensures deterministic serialization with sorted keys and normalized formatting.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonAssert
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
// Ensure deterministic property ordering
|
||||
PropertyOrder = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that two JSON strings are canonically equivalent.
|
||||
/// </summary>
|
||||
/// <param name="expected">The expected JSON.</param>
|
||||
/// <param name="actual">The actual JSON.</param>
|
||||
public static void Equal(string expected, string actual)
|
||||
{
|
||||
var expectedCanonical = Canonicalize(expected);
|
||||
var actualCanonical = Canonicalize(actual);
|
||||
|
||||
if (expectedCanonical != actualCanonical)
|
||||
{
|
||||
throw new CanonicalJsonAssertException(
|
||||
$"JSON mismatch:\nExpected (canonical):\n{expectedCanonical}\n\nActual (canonical):\n{actualCanonical}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that two objects produce canonically equivalent JSON when serialized.
|
||||
/// </summary>
|
||||
public static void EquivalentObjects<T>(T expected, T actual)
|
||||
{
|
||||
var expectedJson = JsonSerializer.Serialize(expected, CanonicalOptions);
|
||||
var actualJson = JsonSerializer.Serialize(actual, CanonicalOptions);
|
||||
|
||||
Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes a JSON string by parsing and re-serializing with deterministic formatting.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(doc.RootElement, CanonicalOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new CanonicalJsonAssertException($"Failed to parse JSON: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable hash of canonical JSON for comparison.
|
||||
/// </summary>
|
||||
public static string ComputeHash(string json)
|
||||
{
|
||||
var canonical = Canonicalize(json);
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that JSON matches a specific hash (for regression testing).
|
||||
/// </summary>
|
||||
public static void MatchesHash(string expectedHash, string json)
|
||||
{
|
||||
var actualHash = ComputeHash(json);
|
||||
if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new CanonicalJsonAssertException(
|
||||
$"JSON hash mismatch:\nExpected hash: {expectedHash}\nActual hash: {actualHash}\n\nJSON (canonical):\n{Canonicalize(json)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when canonical JSON assertions fail.
|
||||
/// </summary>
|
||||
public sealed class CanonicalJsonAssertException : Exception
|
||||
{
|
||||
public CanonicalJsonAssertException(string message) : base(message) { }
|
||||
public CanonicalJsonAssertException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
162
src/__Libraries/StellaOps.TestKit/Observability/OtelCapture.cs
Normal file
162
src/__Libraries/StellaOps.TestKit/Observability/OtelCapture.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System.Diagnostics;
|
||||
using OpenTelemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Captures OpenTelemetry traces and spans during test execution for assertion.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage:
|
||||
/// <code>
|
||||
/// using var capture = new OtelCapture();
|
||||
///
|
||||
/// // Execute code that emits traces
|
||||
/// await MyService.DoWorkAsync();
|
||||
///
|
||||
/// // Assert traces were emitted
|
||||
/// capture.AssertHasSpan("MyService.DoWork");
|
||||
/// capture.AssertHasTag("user_id", "123");
|
||||
/// capture.AssertSpanCount(expectedCount: 3);
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class OtelCapture : IDisposable
|
||||
{
|
||||
private readonly List<Activity> _capturedActivities = new();
|
||||
private readonly ActivityListener _listener;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OTel capture and starts listening for activities.
|
||||
/// </summary>
|
||||
/// <param name="activitySourceName">Optional activity source name filter. If null, captures all activities.</param>
|
||||
public OtelCapture(string? activitySourceName = null)
|
||||
{
|
||||
_listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => activitySourceName == null || source.Name == activitySourceName,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
|
||||
ActivityStopped = activity =>
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
_capturedActivities.Add(activity);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ActivitySource.AddActivityListener(_listener);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured activities (spans).
|
||||
/// </summary>
|
||||
public IReadOnlyList<Activity> CapturedActivities
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
return _capturedActivities.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that a span with the specified name was captured.
|
||||
/// </summary>
|
||||
public void AssertHasSpan(string spanName)
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
Assert.Contains(_capturedActivities, a => a.DisplayName == spanName || a.OperationName == spanName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that at least one span has the specified tag (attribute).
|
||||
/// </summary>
|
||||
public void AssertHasTag(string tagKey, string expectedValue)
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
var found = _capturedActivities.Any(a =>
|
||||
a.Tags.Any(tag => tag.Key == tagKey && tag.Value == expectedValue));
|
||||
|
||||
Assert.True(found, $"No span found with tag {tagKey}={expectedValue}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that exactly the specified number of spans were captured.
|
||||
/// </summary>
|
||||
public void AssertSpanCount(int expectedCount)
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
Assert.Equal(expectedCount, _capturedActivities.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that a span with the specified name has the expected tag.
|
||||
/// </summary>
|
||||
public void AssertSpanHasTag(string spanName, string tagKey, string expectedValue)
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
var span = _capturedActivities.FirstOrDefault(a =>
|
||||
a.DisplayName == spanName || a.OperationName == spanName);
|
||||
|
||||
Assert.NotNull(span);
|
||||
|
||||
var tag = span.Tags.FirstOrDefault(t => t.Key == tagKey);
|
||||
Assert.True(tag.Key != null, $"Tag '{tagKey}' not found in span '{spanName}'");
|
||||
Assert.Equal(expectedValue, tag.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that spans form a valid parent-child hierarchy.
|
||||
/// </summary>
|
||||
public void AssertHierarchy(string parentSpanName, string childSpanName)
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
var parent = _capturedActivities.FirstOrDefault(a =>
|
||||
a.DisplayName == parentSpanName || a.OperationName == parentSpanName);
|
||||
var child = _capturedActivities.FirstOrDefault(a =>
|
||||
a.DisplayName == childSpanName || a.OperationName == childSpanName);
|
||||
|
||||
Assert.NotNull(parent);
|
||||
Assert.NotNull(child);
|
||||
Assert.Equal(parent.SpanId, child.ParentSpanId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured activities.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_capturedActivities)
|
||||
{
|
||||
_capturedActivities.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the capture and stops listening for activities.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_listener?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +1,28 @@
|
||||
# StellaOps.TestKit
|
||||
|
||||
Test infrastructure and fixtures for StellaOps projects. Provides deterministic time/random, canonical JSON assertions, snapshot testing, database fixtures, and OpenTelemetry capture.
|
||||
Testing infrastructure for StellaOps - deterministic helpers, fixtures, and assertions.
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
### Deterministic Time
|
||||
```csharp
|
||||
using StellaOps.TestKit.Time;
|
||||
|
||||
// Create a clock at a fixed time
|
||||
var clock = new DeterministicClock();
|
||||
var now = clock.UtcNow; // 2025-01-01T00:00:00Z
|
||||
|
||||
// Advance time
|
||||
clock.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Or use helpers
|
||||
var clock2 = DeterministicClockExtensions.AtTestEpoch();
|
||||
var clock3 = DeterministicClockExtensions.At("2025-06-15T10:30:00Z");
|
||||
```
|
||||
|
||||
### Deterministic Random
|
||||
```csharp
|
||||
using StellaOps.TestKit.Random;
|
||||
|
||||
// Create deterministic RNG with standard test seed (42)
|
||||
var rng = DeterministicRandomExtensions.WithTestSeed();
|
||||
|
||||
// Generate reproducible values
|
||||
var number = rng.Next(1, 100);
|
||||
var text = rng.NextString(10);
|
||||
var item = rng.PickOne(new[] { "a", "b", "c" });
|
||||
```
|
||||
|
||||
### Canonical JSON Assertions
|
||||
```csharp
|
||||
using StellaOps.TestKit.Json;
|
||||
|
||||
// Assert JSON equality (ignores formatting)
|
||||
CanonicalJsonAssert.Equal(expectedJson, actualJson);
|
||||
|
||||
// Assert object equivalence
|
||||
CanonicalJsonAssert.EquivalentObjects(expectedObj, actualObj);
|
||||
|
||||
// Hash-based regression testing
|
||||
var hash = CanonicalJsonAssert.ComputeHash(json);
|
||||
CanonicalJsonAssert.MatchesHash("abc123...", json);
|
||||
using var time = new DeterministicTime(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
var timestamp = time.UtcNow; // Always 2026-01-15T10:30:00Z
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
```csharp
|
||||
using StellaOps.TestKit.Snapshots;
|
||||
|
||||
public class MyTests
|
||||
{
|
||||
[Fact]
|
||||
public void TestOutput()
|
||||
{
|
||||
var output = GenerateSomeOutput();
|
||||
|
||||
// Compare against __snapshots__/test_output.txt
|
||||
var snapshotPath = SnapshotHelper.GetSnapshotPath("test_output");
|
||||
SnapshotHelper.VerifySnapshot(output, snapshotPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestJsonOutput()
|
||||
{
|
||||
var obj = new { Name = "test", Value = 42 };
|
||||
|
||||
// Compare JSON serialization
|
||||
var snapshotPath = SnapshotHelper.GetSnapshotPath("test_json", ".json");
|
||||
SnapshotHelper.VerifyJsonSnapshot(obj, snapshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Update snapshots: set environment variable UPDATE_SNAPSHOTS=1
|
||||
SnapshotAssert.MatchesSnapshot(sbom, "TestSbom");
|
||||
// Update: UPDATE_SNAPSHOTS=1 dotnet test
|
||||
```
|
||||
|
||||
### PostgreSQL Fixture
|
||||
### PostgreSQL Integration
|
||||
```csharp
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
[Collection("Postgres")]
|
||||
public class DatabaseTests
|
||||
public class Tests : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private readonly PostgresFixture _postgres;
|
||||
|
||||
public DatabaseTests(PostgresFixture postgres)
|
||||
{
|
||||
_postgres = postgres;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestQuery()
|
||||
{
|
||||
// Use connection string
|
||||
await using var conn = new Npgsql.NpgsqlConnection(_postgres.ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Execute SQL
|
||||
await _postgres.ExecuteSqlAsync("CREATE TABLE test (id INT)");
|
||||
|
||||
// Create additional databases
|
||||
await _postgres.CreateDatabaseAsync("otherdb");
|
||||
}
|
||||
public async Task TestDb() { /* use _fixture.ConnectionString */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Valkey/Redis Fixture
|
||||
```csharp
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
[Collection("Valkey")]
|
||||
public class CacheTests
|
||||
{
|
||||
private readonly ValkeyFixture _valkey;
|
||||
|
||||
public CacheTests(ValkeyFixture valkey)
|
||||
{
|
||||
_valkey = valkey;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestCache()
|
||||
{
|
||||
var connectionString = _valkey.ConnectionString;
|
||||
// Use with your Redis/Valkey client
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenTelemetry Capture
|
||||
```csharp
|
||||
using StellaOps.TestKit.Telemetry;
|
||||
|
||||
[Fact]
|
||||
public void TestTracing()
|
||||
{
|
||||
using var otel = new OTelCapture("my-service");
|
||||
|
||||
// Code that emits traces
|
||||
using (var activity = otel.ActivitySource.StartActivity("operation"))
|
||||
{
|
||||
activity?.SetTag("key", "value");
|
||||
}
|
||||
|
||||
// Assert traces
|
||||
otel.AssertActivityExists("operation");
|
||||
otel.AssertActivityHasTag("operation", "key", "value");
|
||||
|
||||
// Get summary for debugging
|
||||
Console.WriteLine(otel.GetTraceSummary());
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Tests
|
||||
|
||||
Add to your test project:
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Determinism**: All utilities produce reproducible results
|
||||
- **Offline-first**: No network dependencies (uses Testcontainers for local infrastructure)
|
||||
- **Minimal dependencies**: Only essential packages
|
||||
- **xUnit-friendly**: Works seamlessly with xUnit fixtures and collections
|
||||
See full documentation in this README.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
namespace StellaOps.TestKit.Random;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic random number generator for testing with reproducible sequences.
|
||||
/// </summary>
|
||||
public sealed class DeterministicRandom
|
||||
{
|
||||
private readonly System.Random _rng;
|
||||
private readonly int _seed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new deterministic random number generator with the specified seed.
|
||||
/// </summary>
|
||||
/// <param name="seed">The seed value. If null, uses 42 (standard test seed).</param>
|
||||
public DeterministicRandom(int? seed = null)
|
||||
{
|
||||
_seed = seed ?? 42;
|
||||
_rng = new System.Random(_seed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seed used for this random number generator.
|
||||
/// </summary>
|
||||
public int Seed => _seed;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a non-negative random integer.
|
||||
/// </summary>
|
||||
public int Next() => _rng.Next();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a non-negative random integer less than the specified maximum.
|
||||
/// </summary>
|
||||
public int Next(int maxValue) => _rng.Next(maxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random integer within the specified range.
|
||||
/// </summary>
|
||||
public int Next(int minValue, int maxValue) => _rng.Next(minValue, maxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random double between 0.0 and 1.0.
|
||||
/// </summary>
|
||||
public double NextDouble() => _rng.NextDouble();
|
||||
|
||||
/// <summary>
|
||||
/// Fills the specified byte array with random bytes.
|
||||
/// </summary>
|
||||
public void NextBytes(byte[] buffer) => _rng.NextBytes(buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Fills the specified span with random bytes.
|
||||
/// </summary>
|
||||
public void NextBytes(Span<byte> buffer) => _rng.NextBytes(buffer);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random boolean value.
|
||||
/// </summary>
|
||||
public bool NextBool() => _rng.Next(2) == 1;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random string of the specified length using alphanumeric characters.
|
||||
/// </summary>
|
||||
public string NextString(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = chars[_rng.Next(chars.Length)];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a random element from the specified collection.
|
||||
/// </summary>
|
||||
public T PickOne<T>(IReadOnlyList<T> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Cannot pick from empty collection", nameof(items));
|
||||
}
|
||||
return items[_rng.Next(items.Count)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for working with deterministic random generators in tests.
|
||||
/// </summary>
|
||||
public static class DeterministicRandomExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard test seed value.
|
||||
/// </summary>
|
||||
public const int TestSeed = 42;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic random generator with the standard test seed.
|
||||
/// </summary>
|
||||
public static DeterministicRandom WithTestSeed() => new(TestSeed);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic random generator with a specific seed.
|
||||
/// </summary>
|
||||
public static DeterministicRandom WithSeed(int seed) => new(seed);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TestKit.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for snapshot testing - comparing test output against golden files.
|
||||
/// </summary>
|
||||
public static class SnapshotHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions DefaultOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that actual content matches a snapshot file.
|
||||
/// </summary>
|
||||
/// <param name="actual">The actual content to verify.</param>
|
||||
/// <param name="snapshotPath">Path to the snapshot file.</param>
|
||||
/// <param name="updateSnapshots">If true, updates the snapshot file instead of comparing. Use for regenerating snapshots.</param>
|
||||
public static void VerifySnapshot(string actual, string snapshotPath, bool updateSnapshots = false)
|
||||
{
|
||||
var normalizedActual = NormalizeLineEndings(actual);
|
||||
|
||||
if (updateSnapshots)
|
||||
{
|
||||
// Update mode: write the snapshot
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||
File.WriteAllText(snapshotPath, normalizedActual, Encoding.UTF8);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify mode: compare against existing snapshot
|
||||
if (!File.Exists(snapshotPath))
|
||||
{
|
||||
throw new SnapshotMismatchException(
|
||||
$"Snapshot file not found: {snapshotPath}\n\nTo create it, run with updateSnapshots=true or set environment variable UPDATE_SNAPSHOTS=1");
|
||||
}
|
||||
|
||||
var expected = File.ReadAllText(snapshotPath, Encoding.UTF8);
|
||||
var normalizedExpected = NormalizeLineEndings(expected);
|
||||
|
||||
if (normalizedActual != normalizedExpected)
|
||||
{
|
||||
throw new SnapshotMismatchException(
|
||||
$"Snapshot mismatch for {Path.GetFileName(snapshotPath)}:\n\nExpected:\n{normalizedExpected}\n\nActual:\n{normalizedActual}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an object's JSON serialization matches a snapshot file.
|
||||
/// </summary>
|
||||
public static void VerifyJsonSnapshot<T>(T value, string snapshotPath, bool updateSnapshots = false, JsonSerializerOptions? options = null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, options ?? DefaultOptions);
|
||||
VerifySnapshot(json, snapshotPath, updateSnapshots);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the snapshot directory for the calling test class.
|
||||
/// </summary>
|
||||
/// <param name="testFilePath">Automatically populated by compiler.</param>
|
||||
/// <returns>Path to the __snapshots__ directory next to the test file.</returns>
|
||||
public static string GetSnapshotDirectory([CallerFilePath] string testFilePath = "")
|
||||
{
|
||||
var testDir = Path.GetDirectoryName(testFilePath)!;
|
||||
return Path.Combine(testDir, "__snapshots__");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path for a snapshot file.
|
||||
/// </summary>
|
||||
/// <param name="snapshotName">Name of the snapshot file (without extension).</param>
|
||||
/// <param name="extension">File extension (default: .txt).</param>
|
||||
/// <param name="testFilePath">Automatically populated by compiler.</param>
|
||||
public static string GetSnapshotPath(
|
||||
string snapshotName,
|
||||
string extension = ".txt",
|
||||
[CallerFilePath] string testFilePath = "")
|
||||
{
|
||||
var snapshotDir = GetSnapshotDirectory(testFilePath);
|
||||
var fileName = $"{snapshotName}{extension}";
|
||||
return Path.Combine(snapshotDir, fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes line endings to LF for cross-platform consistency.
|
||||
/// </summary>
|
||||
private static string NormalizeLineEndings(string content)
|
||||
{
|
||||
return content.Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if snapshot update mode is enabled via environment variable.
|
||||
/// </summary>
|
||||
public static bool IsUpdateMode()
|
||||
{
|
||||
var updateEnv = Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS");
|
||||
return string.Equals(updateEnv, "1", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(updateEnv, "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when snapshot verification fails.
|
||||
/// </summary>
|
||||
public sealed class SnapshotMismatchException : Exception
|
||||
{
|
||||
public SnapshotMismatchException(string message) : base(message) { }
|
||||
}
|
||||
@@ -1,30 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>true</IsPackable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<Description>Testing infrastructure and utilities for StellaOps</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>StellaOps.TestKit</AssemblyName>
|
||||
<RootNamespace>StellaOps.TestKit</RootNamespace>
|
||||
<Description>Test infrastructure and fixtures for StellaOps projects - deterministic time/random, canonical JSON, snapshots, and database fixtures</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.abstractions" Version="2.0.3" />
|
||||
<PackageReference Include="xunit.extensibility.core" Version="2.9.2" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="Testcontainers.Redis" Version="4.1.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.10.0" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.10.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" Version="1.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="FsCheck" Version="2.16.6" />
|
||||
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageReference Include="Testcontainers" Version="3.10.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
|
||||
<PackageReference Include="Npgsql" Version="8.0.5" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.TestKit.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Captures OpenTelemetry traces in-memory for testing.
|
||||
/// </summary>
|
||||
public sealed class OTelCapture : IDisposable
|
||||
{
|
||||
private readonly TracerProvider _tracerProvider;
|
||||
private readonly InMemoryExporter _exporter;
|
||||
private readonly ActivitySource _activitySource;
|
||||
|
||||
public OTelCapture(string serviceName = "test-service")
|
||||
{
|
||||
_exporter = new InMemoryExporter();
|
||||
_activitySource = new ActivitySource(serviceName);
|
||||
|
||||
_tracerProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
|
||||
.AddSource(serviceName)
|
||||
.AddInMemoryExporter(_exporter)
|
||||
.Build()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured activities (spans).
|
||||
/// </summary>
|
||||
public IReadOnlyList<Activity> Activities => _exporter.Activities;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the activity source for creating spans in tests.
|
||||
/// </summary>
|
||||
public ActivitySource ActivitySource => _activitySource;
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured activities.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_exporter.Activities.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds activities by operation name.
|
||||
/// </summary>
|
||||
public IEnumerable<Activity> FindByOperationName(string operationName)
|
||||
{
|
||||
return Activities.Where(a => a.OperationName == operationName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds activities by tag value.
|
||||
/// </summary>
|
||||
public IEnumerable<Activity> FindByTag(string tagKey, string tagValue)
|
||||
{
|
||||
return Activities.Where(a => a.Tags.Any(t => t.Key == tagKey && t.Value == tagValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that at least one activity with the specified operation name exists.
|
||||
/// </summary>
|
||||
public void AssertActivityExists(string operationName)
|
||||
{
|
||||
if (!Activities.Any(a => a.OperationName == operationName))
|
||||
{
|
||||
var availableOps = string.Join(", ", Activities.Select(a => a.OperationName).Distinct());
|
||||
throw new OTelAssertException(
|
||||
$"No activity found with operation name '{operationName}'. Available operations: {availableOps}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that an activity has a specific tag.
|
||||
/// </summary>
|
||||
public void AssertActivityHasTag(string operationName, string tagKey, string expectedValue)
|
||||
{
|
||||
var activities = FindByOperationName(operationName).ToList();
|
||||
if (activities.Count == 0)
|
||||
{
|
||||
throw new OTelAssertException($"No activity found with operation name '{operationName}'");
|
||||
}
|
||||
|
||||
var activity = activities.First();
|
||||
var tag = activity.Tags.FirstOrDefault(t => t.Key == tagKey);
|
||||
if (tag.Key == null)
|
||||
{
|
||||
throw new OTelAssertException($"Activity '{operationName}' does not have tag '{tagKey}'");
|
||||
}
|
||||
|
||||
if (tag.Value != expectedValue)
|
||||
{
|
||||
throw new OTelAssertException(
|
||||
$"Tag '{tagKey}' on activity '{operationName}' has value '{tag.Value}' but expected '{expectedValue}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of captured traces for debugging.
|
||||
/// </summary>
|
||||
public string GetTraceSummary()
|
||||
{
|
||||
if (Activities.Count == 0)
|
||||
{
|
||||
return "No traces captured";
|
||||
}
|
||||
|
||||
var summary = new System.Text.StringBuilder();
|
||||
summary.AppendLine($"Captured {Activities.Count} activities:");
|
||||
foreach (var activity in Activities)
|
||||
{
|
||||
summary.AppendLine($" - {activity.OperationName} ({activity.Duration.TotalMilliseconds:F2}ms)");
|
||||
foreach (var tag in activity.Tags)
|
||||
{
|
||||
summary.AppendLine($" {tag.Key} = {tag.Value}");
|
||||
}
|
||||
}
|
||||
return summary.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracerProvider?.Dispose();
|
||||
_activitySource?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory exporter for OpenTelemetry activities.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryExporter
|
||||
{
|
||||
public List<Activity> Activities { get; } = new();
|
||||
|
||||
public void Export(Activity activity)
|
||||
{
|
||||
Activities.Add(activity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when OTel assertions fail.
|
||||
/// </summary>
|
||||
public sealed class OTelAssertException : Exception
|
||||
{
|
||||
public OTelAssertException(string message) : base(message) { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
63
src/__Libraries/StellaOps.TestKit/TestCategories.cs
Normal file
63
src/__Libraries/StellaOps.TestKit/TestCategories.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace StellaOps.TestKit;
|
||||
|
||||
/// <summary>
|
||||
/// Standardized test trait categories for organizing and filtering tests in CI pipelines.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage with xUnit:
|
||||
/// <code>
|
||||
/// [Fact, Trait("Category", TestCategories.Unit)]
|
||||
/// public void TestBusinessLogic() { }
|
||||
///
|
||||
/// [Fact, Trait("Category", TestCategories.Integration)]
|
||||
/// public async Task TestDatabaseAccess() { }
|
||||
/// </code>
|
||||
///
|
||||
/// Filter by category during test runs:
|
||||
/// <code>
|
||||
/// dotnet test --filter "Category=Unit"
|
||||
/// dotnet test --filter "Category!=Live"
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public static class TestCategories
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests: Fast, in-memory, no external dependencies.
|
||||
/// </summary>
|
||||
public const string Unit = "Unit";
|
||||
|
||||
/// <summary>
|
||||
/// Property-based tests: FsCheck/generative testing for invariants.
|
||||
/// </summary>
|
||||
public const string Property = "Property";
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot tests: Golden master regression testing.
|
||||
/// </summary>
|
||||
public const string Snapshot = "Snapshot";
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests: Testcontainers, PostgreSQL, Valkey, etc.
|
||||
/// </summary>
|
||||
public const string Integration = "Integration";
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests: API/WebService contract verification.
|
||||
/// </summary>
|
||||
public const string Contract = "Contract";
|
||||
|
||||
/// <summary>
|
||||
/// Security tests: Cryptographic validation, vulnerability scanning.
|
||||
/// </summary>
|
||||
public const string Security = "Security";
|
||||
|
||||
/// <summary>
|
||||
/// Performance tests: Benchmarking, load testing.
|
||||
/// </summary>
|
||||
public const string Performance = "Performance";
|
||||
|
||||
/// <summary>
|
||||
/// Live tests: Require external services (e.g., Rekor, NuGet feeds). Disabled by default in CI.
|
||||
/// </summary>
|
||||
public const string Live = "Live";
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
namespace StellaOps.TestKit.Time;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic clock for testing that returns a fixed time.
|
||||
/// </summary>
|
||||
public sealed class DeterministicClock
|
||||
{
|
||||
private DateTimeOffset _currentTime;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new deterministic clock with the specified initial time.
|
||||
/// </summary>
|
||||
/// <param name="initialTime">The initial time. If null, uses 2025-01-01T00:00:00Z.</param>
|
||||
public DeterministicClock(DateTimeOffset? initialTime = null)
|
||||
{
|
||||
_currentTime = initialTime ?? new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current time.
|
||||
/// </summary>
|
||||
public DateTimeOffset UtcNow => _currentTime;
|
||||
|
||||
/// <summary>
|
||||
/// Advances the clock by the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration to advance.</param>
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_currentTime = _currentTime.Add(duration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the clock to a specific time.
|
||||
/// </summary>
|
||||
/// <param name="time">The time to set.</param>
|
||||
public void SetTime(DateTimeOffset time)
|
||||
{
|
||||
_currentTime = time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the clock to the initial time.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_currentTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for working with deterministic clocks in tests.
|
||||
/// </summary>
|
||||
public static class DeterministicClockExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard test epoch: 2025-01-01T00:00:00Z
|
||||
/// </summary>
|
||||
public static readonly DateTimeOffset TestEpoch = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a clock at the standard test epoch.
|
||||
/// </summary>
|
||||
public static DeterministicClock AtTestEpoch() => new(TestEpoch);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a clock at a specific ISO 8601 timestamp.
|
||||
/// </summary>
|
||||
public static DeterministicClock At(string iso8601) => new(DateTimeOffset.Parse(iso8601));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.TestKit.Traits;
|
||||
|
||||
/// <summary>
|
||||
/// Trait discoverer for Lane attribute.
|
||||
/// </summary>
|
||||
public sealed class LaneTraitDiscoverer : ITraitDiscoverer
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
|
||||
{
|
||||
var lane = traitAttribute.GetNamedArgument<string>(nameof(LaneAttribute.Lane))
|
||||
?? traitAttribute.GetConstructorArguments().FirstOrDefault()?.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(lane))
|
||||
{
|
||||
yield return new KeyValuePair<string, string>("Lane", lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.TestKit.Traits;
|
||||
|
||||
/// <summary>
|
||||
/// Base attribute for test traits that categorize tests by lane and type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
|
||||
public abstract class TestTraitAttributeBase : Attribute, ITraitAttribute
|
||||
{
|
||||
protected TestTraitAttributeBase(string traitName, string value)
|
||||
{
|
||||
TraitName = traitName;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public string TraitName { get; }
|
||||
public string Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as belonging to a specific test lane.
|
||||
/// Lanes: Unit, Contract, Integration, Security, Performance, Live
|
||||
/// </summary>
|
||||
[TraitDiscoverer("StellaOps.TestKit.Traits.LaneTraitDiscoverer", "StellaOps.TestKit")]
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
|
||||
public sealed class LaneAttribute : Attribute, ITraitAttribute
|
||||
{
|
||||
public LaneAttribute(string lane)
|
||||
{
|
||||
Lane = lane ?? throw new ArgumentNullException(nameof(lane));
|
||||
}
|
||||
|
||||
public string Lane { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test with a specific test type trait.
|
||||
/// Common types: unit, property, snapshot, determinism, integration_postgres, contract, authz, etc.
|
||||
/// </summary>
|
||||
[TraitDiscoverer("StellaOps.TestKit.Traits.TestTypeTraitDiscoverer", "StellaOps.TestKit")]
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
|
||||
public sealed class TestTypeAttribute : Attribute, ITraitAttribute
|
||||
{
|
||||
public TestTypeAttribute(string testType)
|
||||
{
|
||||
TestType = testType ?? throw new ArgumentNullException(nameof(testType));
|
||||
}
|
||||
|
||||
public string TestType { get; }
|
||||
}
|
||||
|
||||
// Lane-specific convenience attributes
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a Unit test.
|
||||
/// </summary>
|
||||
public sealed class UnitTestAttribute : LaneAttribute
|
||||
{
|
||||
public UnitTestAttribute() : base("Unit") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a Contract test.
|
||||
/// </summary>
|
||||
public sealed class ContractTestAttribute : LaneAttribute
|
||||
{
|
||||
public ContractTestAttribute() : base("Contract") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as an Integration test.
|
||||
/// </summary>
|
||||
public sealed class IntegrationTestAttribute : LaneAttribute
|
||||
{
|
||||
public IntegrationTestAttribute() : base("Integration") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a Security test.
|
||||
/// </summary>
|
||||
public sealed class SecurityTestAttribute : LaneAttribute
|
||||
{
|
||||
public SecurityTestAttribute() : base("Security") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a Performance test.
|
||||
/// </summary>
|
||||
public sealed class PerformanceTestAttribute : LaneAttribute
|
||||
{
|
||||
public PerformanceTestAttribute() : base("Performance") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a Live test (requires external connectivity).
|
||||
/// These tests should be opt-in only and never PR-gating.
|
||||
/// </summary>
|
||||
public sealed class LiveTestAttribute : LaneAttribute
|
||||
{
|
||||
public LiveTestAttribute() : base("Live") { }
|
||||
}
|
||||
|
||||
// Test type-specific convenience attributes
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as testing determinism.
|
||||
/// </summary>
|
||||
public sealed class DeterminismTestAttribute : TestTypeAttribute
|
||||
{
|
||||
public DeterminismTestAttribute() : base("determinism") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a snapshot test.
|
||||
/// </summary>
|
||||
public sealed class SnapshotTestAttribute : TestTypeAttribute
|
||||
{
|
||||
public SnapshotTestAttribute() : base("snapshot") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as a property-based test.
|
||||
/// </summary>
|
||||
public sealed class PropertyTestAttribute : TestTypeAttribute
|
||||
{
|
||||
public PropertyTestAttribute() : base("property") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as an authorization test.
|
||||
/// </summary>
|
||||
public sealed class AuthzTestAttribute : TestTypeAttribute
|
||||
{
|
||||
public AuthzTestAttribute() : base("authz") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as testing OpenTelemetry traces.
|
||||
/// </summary>
|
||||
public sealed class OTelTestAttribute : TestTypeAttribute
|
||||
{
|
||||
public OTelTestAttribute() : base("otel") { }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.TestKit.Traits;
|
||||
|
||||
/// <summary>
|
||||
/// Trait discoverer for TestType attribute.
|
||||
/// </summary>
|
||||
public sealed class TestTypeTraitDiscoverer : ITraitDiscoverer
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
|
||||
{
|
||||
var testType = traitAttribute.GetNamedArgument<string>(nameof(TestTypeAttribute.TestType))
|
||||
?? traitAttribute.GetConstructorArguments().FirstOrDefault()?.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(testType))
|
||||
{
|
||||
yield return new KeyValuePair<string, string>("TestType", testType);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user