Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Concelier.Testing;
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<IsTestProject>false</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3">
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="xunit" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.Persistence/StellaOps.Concelier.Persistence.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test method as a migration test that requires database isolation.
|
||||
/// When applied, the test will automatically truncate all tables before execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this attribute on test methods that modify database state and need isolation
|
||||
/// from other tests. The attribute ensures a clean database state by truncating
|
||||
/// all tables in the test schema before the test runs.
|
||||
///
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// [MigrationTest]
|
||||
/// public async Task Should_Insert_Record_Successfully()
|
||||
/// {
|
||||
/// // Database tables are empty when this test starts
|
||||
/// await _fixture.ExecuteSqlAsync("INSERT INTO ...");
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public sealed class MigrationTestAttribute : BeforeAfterTestAttribute
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to truncate tables before the test runs.
|
||||
/// Default is true.
|
||||
/// </summary>
|
||||
public bool TruncateBefore { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to truncate tables after the test runs.
|
||||
/// Default is false (let the next test with TruncateBefore handle cleanup).
|
||||
/// </summary>
|
||||
public bool TruncateAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets specific table names to truncate. If null or empty, all tables are truncated.
|
||||
/// </summary>
|
||||
public string[]? Tables { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Called before the test method runs.
|
||||
/// </summary>
|
||||
public override void Before(MethodInfo methodUnderTest)
|
||||
{
|
||||
if (!TruncateBefore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find the fixture from the test class
|
||||
var testClass = methodUnderTest.DeclaringType;
|
||||
if (testClass is null) return;
|
||||
|
||||
// Look for a field or property of type PostgresIntegrationFixture
|
||||
var fixtureField = testClass
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
|
||||
.FirstOrDefault(f => typeof(PostgresIntegrationFixture).IsAssignableFrom(f.FieldType));
|
||||
|
||||
var fixtureProperty = testClass
|
||||
.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
|
||||
.FirstOrDefault(p => typeof(PostgresIntegrationFixture).IsAssignableFrom(p.PropertyType));
|
||||
|
||||
// Note: We can't access the instance here in xUnit's BeforeAfterTestAttribute
|
||||
// This is a limitation - the actual truncation needs to be done via a different mechanism
|
||||
// See MigrationTestFixture for a better approach
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after the test method runs.
|
||||
/// </summary>
|
||||
public override void After(MethodInfo methodUnderTest)
|
||||
{
|
||||
// Cleanup is optional and typically not needed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test fixture wrapper that provides automatic table truncation between tests.
|
||||
/// </summary>
|
||||
/// <typeparam name="TFixture">The underlying PostgreSQL integration fixture type.</typeparam>
|
||||
public abstract class MigrationTestBase<TFixture> : IAsyncLifetime
|
||||
where TFixture : PostgresIntegrationFixture
|
||||
{
|
||||
private readonly TFixture _fixture;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying test fixture.
|
||||
/// </summary>
|
||||
protected TFixture Fixture => _fixture;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for direct database access.
|
||||
/// </summary>
|
||||
protected string ConnectionString => _fixture.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name for this test.
|
||||
/// </summary>
|
||||
protected string SchemaName => _fixture.SchemaName;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new migration test base with the specified fixture.
|
||||
/// </summary>
|
||||
protected MigrationTestBase(TFixture fixture)
|
||||
{
|
||||
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before each test. Override to customize initialization.
|
||||
/// By default, truncates all tables for test isolation.
|
||||
/// </summary>
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after each test. Override to customize cleanup.
|
||||
/// </summary>
|
||||
public virtual Task DisposeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes raw SQL for test setup.
|
||||
/// </summary>
|
||||
protected Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default)
|
||||
=> _fixture.ExecuteSqlAsync(sql, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for migration tests that require database isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Apply [Collection(MigrationTestCollection.Name)] to test classes that share a database fixture.
|
||||
/// Tests within the collection run sequentially to avoid database conflicts.
|
||||
/// </remarks>
|
||||
public static class MigrationTestCollection
|
||||
{
|
||||
/// <summary>
|
||||
/// The collection name for migration tests.
|
||||
/// </summary>
|
||||
public const string Name = "MigrationTests";
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
namespace StellaOps.Messaging.Testing.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating test messages with customizable properties.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class TestMessageBuilder<TMessage> where TMessage : class, new()
|
||||
{
|
||||
private readonly Dictionary<string, object?> _properties = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property on the message.
|
||||
/// </summary>
|
||||
public TestMessageBuilder<TMessage> With(string propertyName, object? value)
|
||||
{
|
||||
_properties[propertyName] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message with all configured properties.
|
||||
/// </summary>
|
||||
public TMessage Build()
|
||||
{
|
||||
var message = new TMessage();
|
||||
var type = typeof(TMessage);
|
||||
|
||||
foreach (var (propertyName, value) in _properties)
|
||||
{
|
||||
var property = type.GetProperty(propertyName);
|
||||
if (property is not null && property.CanWrite)
|
||||
{
|
||||
property.SetValue(message, value);
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple test message for queue testing.
|
||||
/// </summary>
|
||||
public sealed record TestQueueMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message ID.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message content.
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the priority.
|
||||
/// </summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the message was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for creating test messages.
|
||||
/// </summary>
|
||||
public static class TestMessageExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new test queue message with the specified content.
|
||||
/// </summary>
|
||||
public static TestQueueMessage CreateTestMessage(this string content, string? tenantId = null)
|
||||
{
|
||||
return new TestQueueMessage
|
||||
{
|
||||
Content = content,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple test messages for batch testing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TestQueueMessage> CreateTestMessages(int count, string? tenantId = null)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new TestQueueMessage
|
||||
{
|
||||
Content = $"Test message {i}",
|
||||
TenantId = tenantId,
|
||||
Priority = i % 3
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for in-memory messaging transport.
|
||||
/// Provides fast, isolated test infrastructure without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMessagingFixture : IAsyncLifetime
|
||||
{
|
||||
private InMemoryQueueRegistry _registry = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared queue registry for test coordination.
|
||||
/// </summary>
|
||||
public InMemoryQueueRegistry Registry => _registry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_registry = new InMemoryQueueRegistry();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_registry.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with in-memory transport.
|
||||
/// </summary>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton(_registry);
|
||||
services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using in-memory transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using in-memory transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all queues and caches in the registry.
|
||||
/// Call this between tests for isolation.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_registry.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for in-memory messaging fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(InMemoryMessagingFixtureCollection))]
|
||||
public class InMemoryMessagingFixtureCollection : ICollectionFixture<InMemoryMessagingFixture>
|
||||
{
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Postgres;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for PostgreSQL testcontainer.
|
||||
/// Provides a containerized PostgreSQL instance for queue integration tests.
|
||||
/// </summary>
|
||||
public sealed class PostgresQueueFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL instance.
|
||||
/// </summary>
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresQueueFixture"/> class.
|
||||
/// </summary>
|
||||
public PostgresQueueFixture()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("stellaops_messaging_test")
|
||||
.WithUsername("test")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with PostgreSQL transport.
|
||||
/// </summary>
|
||||
/// <param name="configureOptions">Optional configuration for PostgreSQL options.</param>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider(Action<PostgresTransportOptions>? configureOptions = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddOptions<PostgresTransportOptions>().Configure(options =>
|
||||
{
|
||||
options.ConnectionString = ConnectionString;
|
||||
options.Schema = "messaging";
|
||||
options.AutoCreateTables = true;
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<PostgresConnectionFactory>();
|
||||
services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using PostgreSQL transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using PostgreSQL transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for PostgreSQL fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(PostgresQueueFixtureCollection))]
|
||||
public class PostgresQueueFixtureCollection : ICollectionFixture<PostgresQueueFixture>
|
||||
{
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for Valkey testcontainer.
|
||||
/// Provides a containerized Valkey instance for integration tests.
|
||||
/// </summary>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly IContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the Valkey instance.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host of the Valkey instance.
|
||||
/// </summary>
|
||||
public string Host => _container.Hostname;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapped port for the Valkey instance.
|
||||
/// </summary>
|
||||
public ushort Port { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ValkeyFixture"/> class.
|
||||
/// </summary>
|
||||
public ValkeyFixture()
|
||||
{
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||
.UntilPortIsAvailable(6379))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
Port = _container.GetMappedPublicPort(6379);
|
||||
ConnectionString = $"{Host}:{Port}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with Valkey transport.
|
||||
/// </summary>
|
||||
/// <param name="configureOptions">Optional configuration for Valkey options.</param>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider(Action<ValkeyTransportOptions>? configureOptions = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddOptions<ValkeyTransportOptions>().Configure(options =>
|
||||
{
|
||||
options.ConnectionString = ConnectionString;
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<ValkeyConnectionFactory>();
|
||||
services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using Valkey transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using Valkey transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Valkey fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(ValkeyFixtureCollection))]
|
||||
public class ValkeyFixtureCollection : ICollectionFixture<ValkeyFixture>
|
||||
{
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Testing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.Postgres\StellaOps.Messaging.Transport.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.InMemory\StellaOps.Messaging.Transport.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" Version="2.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,210 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Testing.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating test frames with sensible defaults.
|
||||
/// </summary>
|
||||
public static class TestFrameFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a request frame with the specified payload.
|
||||
/// </summary>
|
||||
public static Frame CreateRequestFrame(
|
||||
byte[]? payload = null,
|
||||
string? correlationId = null,
|
||||
FrameType frameType = FrameType.Request)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
Payload = payload ?? Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response frame for the given correlation ID.
|
||||
/// </summary>
|
||||
public static Frame CreateResponseFrame(
|
||||
string correlationId,
|
||||
byte[]? payload = null)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = correlationId,
|
||||
Payload = payload ?? Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a hello frame for service registration.
|
||||
/// </summary>
|
||||
public static Frame CreateHelloFrame(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "test",
|
||||
string instanceId = "test-instance",
|
||||
IReadOnlyList<EndpointDescriptor>? endpoints = null)
|
||||
{
|
||||
var helloPayload = new HelloPayload
|
||||
{
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Endpoints = endpoints ?? []
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(helloPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a heartbeat frame.
|
||||
/// </summary>
|
||||
public static Frame CreateHeartbeatFrame(
|
||||
string instanceId = "test-instance",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
int inFlightRequestCount = 0,
|
||||
double errorRate = 0.0)
|
||||
{
|
||||
var heartbeatPayload = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
Status = status,
|
||||
InFlightRequestCount = inFlightRequestCount,
|
||||
ErrorRate = errorRate,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(heartbeatPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancel frame for the given correlation ID.
|
||||
/// </summary>
|
||||
public static Frame CreateCancelFrame(
|
||||
string correlationId,
|
||||
string? reason = null)
|
||||
{
|
||||
var cancelPayload = new CancelPayload
|
||||
{
|
||||
Reason = reason ?? CancelReasons.Timeout
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Cancel,
|
||||
CorrelationId = correlationId,
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cancelPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a frame with a specific payload size for testing limits.
|
||||
/// </summary>
|
||||
public static Frame CreateFrameWithPayloadSize(int payloadSize)
|
||||
{
|
||||
var payload = new byte[payloadSize];
|
||||
Random.Shared.NextBytes(payload);
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a request frame from JSON content.
|
||||
/// </summary>
|
||||
public static RequestFrame CreateTypedRequestFrame<T>(
|
||||
T request,
|
||||
string method = "POST",
|
||||
string path = "/test",
|
||||
Dictionary<string, string>? headers = null)
|
||||
{
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Headers = headers ?? new Dictionary<string, string>(),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(request)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an endpoint descriptor for testing.
|
||||
/// </summary>
|
||||
public static EndpointDescriptor CreateEndpointDescriptor(
|
||||
string method = "GET",
|
||||
string path = "/test",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
int timeoutSeconds = 30,
|
||||
bool supportsStreaming = false,
|
||||
IReadOnlyList<ClaimRequirement>? requiringClaims = null)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(timeoutSeconds),
|
||||
SupportsStreaming = supportsStreaming,
|
||||
RequiringClaims = requiringClaims ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance descriptor for testing.
|
||||
/// </summary>
|
||||
public static InstanceDescriptor CreateInstanceDescriptor(
|
||||
string instanceId = "test-instance",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "test")
|
||||
{
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a claim requirement for testing.
|
||||
/// </summary>
|
||||
public static ClaimRequirement CreateClaimRequirement(
|
||||
string type,
|
||||
string? value = null)
|
||||
{
|
||||
return new ClaimRequirement
|
||||
{
|
||||
Type = type,
|
||||
Value = value
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Base test fixture for Router tests providing common utilities.
|
||||
/// Implements IAsyncLifetime for async setup/teardown.
|
||||
/// </summary>
|
||||
public abstract class RouterTestFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a null logger factory for tests that don't need logging.
|
||||
/// </summary>
|
||||
protected ILoggerFactory LoggerFactory { get; } = NullLoggerFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a null logger for tests that don't need logging.
|
||||
/// </summary>
|
||||
protected ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancellation token that times out after the specified duration.
|
||||
/// </summary>
|
||||
protected static CancellationToken CreateTimeoutToken(TimeSpan timeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
return cts.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancellation token that times out after 5 seconds (default for tests).
|
||||
/// </summary>
|
||||
protected static CancellationToken CreateTestTimeoutToken()
|
||||
{
|
||||
return CreateTimeoutToken(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a condition to be true with timeout.
|
||||
/// </summary>
|
||||
protected static async Task WaitForConditionAsync(
|
||||
Func<bool> condition,
|
||||
TimeSpan timeout,
|
||||
TimeSpan? pollInterval = null)
|
||||
{
|
||||
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
return;
|
||||
|
||||
await Task.Delay(interval);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an async condition to be true with timeout.
|
||||
/// </summary>
|
||||
protected static async Task WaitForConditionAsync(
|
||||
Func<Task<bool>> condition,
|
||||
TimeSpan timeout,
|
||||
TimeSpan? pollInterval = null)
|
||||
{
|
||||
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (await condition())
|
||||
return;
|
||||
|
||||
await Task.Delay(interval);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override for async initialization.
|
||||
/// </summary>
|
||||
public virtual Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Override for async cleanup.
|
||||
/// </summary>
|
||||
public virtual Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture for sharing state across tests in the same collection.
|
||||
/// </summary>
|
||||
public abstract class RouterCollectionFixture : IAsyncLifetime
|
||||
{
|
||||
public virtual Task InitializeAsync() => Task.CompletedTask;
|
||||
public virtual Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Testing.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// A mock connection state for testing routing and connection management.
|
||||
/// </summary>
|
||||
public sealed class MockConnectionState
|
||||
{
|
||||
public string ConnectionId { get; init; } = Guid.NewGuid().ToString("N");
|
||||
public string ServiceName { get; init; } = "test-service";
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
public string Region { get; init; } = "test";
|
||||
public string InstanceId { get; init; } = "test-instance";
|
||||
public InstanceHealthStatus HealthStatus { get; set; } = InstanceHealthStatus.Healthy;
|
||||
public DateTimeOffset ConnectedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset LastHeartbeatUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public int InflightRequests { get; set; }
|
||||
public int Weight { get; set; } = 100;
|
||||
public List<EndpointDescriptor> Endpoints { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a connection state for testing.
|
||||
/// </summary>
|
||||
public static MockConnectionState Create(
|
||||
string? serviceName = null,
|
||||
string? instanceId = null,
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new MockConnectionState
|
||||
{
|
||||
ServiceName = serviceName ?? "test-service",
|
||||
InstanceId = instanceId ?? $"instance-{Guid.NewGuid():N}",
|
||||
HealthStatus = status
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple connection states simulating a service cluster.
|
||||
/// </summary>
|
||||
public static List<MockConnectionState> CreateCluster(
|
||||
string serviceName,
|
||||
int instanceCount,
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return Enumerable.Range(0, instanceCount)
|
||||
.Select(i => new MockConnectionState
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
InstanceId = $"{serviceName}-{i}",
|
||||
HealthStatus = status
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Router.Testing.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// A logger that records all log entries for assertions.
|
||||
/// </summary>
|
||||
public sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly ConcurrentQueue<LogEntry> _entries = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recorded log entries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogEntry> Entries => _entries.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries filtered by log level.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> GetEntries(LogLevel level) =>
|
||||
_entries.Where(e => e.Level == level);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all error entries.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> Errors => GetEntries(LogLevel.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all warning entries.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> Warnings => GetEntries(LogLevel.Warning);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
while (_entries.TryDequeue(out _)) { }
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
|
||||
NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Enqueue(new LogEntry
|
||||
{
|
||||
Level = logLevel,
|
||||
EventId = eventId,
|
||||
Message = formatter(state, exception),
|
||||
Exception = exception,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recorded log entry.
|
||||
/// </summary>
|
||||
public sealed record LogEntry
|
||||
{
|
||||
public required LogLevel Level { get; init; }
|
||||
public required EventId EventId { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public Exception? Exception { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logger factory that creates recording loggers.
|
||||
/// </summary>
|
||||
public sealed class RecordingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _loggers = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) =>
|
||||
(ILogger)_loggers.GetOrAdd(categoryName, _ => new RecordingLogger<object>());
|
||||
|
||||
public ILogger<T> CreateLogger<T>() =>
|
||||
(ILogger<T>)_loggers.GetOrAdd(typeof(T).FullName!, _ => new RecordingLogger<T>());
|
||||
|
||||
public RecordingLogger<T>? GetLogger<T>() =>
|
||||
_loggers.TryGetValue(typeof(T).FullName!, out var logger)
|
||||
? logger as RecordingLogger<T>
|
||||
: null;
|
||||
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Testing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.core" Version="2.6.6" />
|
||||
<PackageReference Include="xunit" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.Determinism.Properties;
|
||||
|
||||
@@ -106,9 +108,9 @@ public class CanonicalJsonDeterminismProperties
|
||||
[Property(Arbitrary = [typeof(JsonObjectArbitraries)], MaxTest = 100)]
|
||||
public Property HashIsStable(Dictionary<string, object?> data)
|
||||
{
|
||||
var hash1 = CanonJson.HashSha256Prefixed(data);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(data);
|
||||
var hash3 = CanonJson.HashSha256Prefixed(data);
|
||||
var hash1 = CanonJson.HashPrefixed(data);
|
||||
var hash2 = CanonJson.HashPrefixed(data);
|
||||
var hash3 = CanonJson.HashPrefixed(data);
|
||||
|
||||
return (hash1 == hash2 && hash2 == hash3)
|
||||
.Label($"Hash should be stable: {hash1}");
|
||||
@@ -140,16 +142,16 @@ public class CanonicalJsonDeterminismProperties
|
||||
return true.ToProperty();
|
||||
|
||||
// Create multiple permutations using different orderings
|
||||
var rng = new Random(seed.Get);
|
||||
var rng = new System.Random(seed.Get);
|
||||
var ordering1 = dict.OrderBy(_ => rng.Next()).ToDictionary();
|
||||
rng = new Random(seed.Get + 1);
|
||||
rng = new System.Random(seed.Get + 1);
|
||||
var ordering2 = dict.OrderBy(_ => rng.Next()).ToDictionary();
|
||||
rng = new Random(seed.Get + 2);
|
||||
rng = new System.Random(seed.Get + 2);
|
||||
var ordering3 = dict.OrderBy(_ => rng.Next()).ToDictionary();
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(ordering1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(ordering2);
|
||||
var hash3 = CanonJson.HashSha256Prefixed(ordering3);
|
||||
var hash1 = CanonJson.HashPrefixed(ordering1);
|
||||
var hash2 = CanonJson.HashPrefixed(ordering2);
|
||||
var hash3 = CanonJson.HashPrefixed(ordering3);
|
||||
|
||||
return (hash1 == hash2 && hash2 == hash3)
|
||||
.Label($"All permutations should produce same hash: {hash1}");
|
||||
@@ -161,8 +163,8 @@ public class CanonicalJsonDeterminismProperties
|
||||
[Fact]
|
||||
public void EmptyObjectHasStableHash()
|
||||
{
|
||||
var hash1 = CanonJson.HashSha256Prefixed(new Dictionary<string, object>());
|
||||
var hash2 = CanonJson.HashSha256Prefixed(new Dictionary<string, object>());
|
||||
var hash1 = CanonJson.HashPrefixed(new Dictionary<string, object>());
|
||||
var hash2 = CanonJson.HashPrefixed(new Dictionary<string, object>());
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
hash1.Should().StartWith("sha256:");
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.Determinism.Properties;
|
||||
|
||||
@@ -36,7 +38,7 @@ public class DigestComputationDeterminismProperties
|
||||
[Property(Arbitrary = [typeof(JsonObjectArbitraries)], MaxTest = 100)]
|
||||
public Property PrefixedHashFormatIsConsistent(Dictionary<string, string> data)
|
||||
{
|
||||
var hash = CanonJson.HashSha256Prefixed(data);
|
||||
var hash = CanonJson.HashPrefixed(data);
|
||||
|
||||
var validFormat = hash.StartsWith("sha256:") && hash.Length == 71; // "sha256:" + 64 hex chars
|
||||
|
||||
@@ -136,7 +138,7 @@ public class DigestComputationDeterminismProperties
|
||||
{
|
||||
var actualSize = Math.Min(size.Get, 100_000); // Cap at 100KB for test performance
|
||||
var data = new byte[actualSize];
|
||||
new Random(42).NextBytes(data); // Deterministic random
|
||||
new System.Random(42).NextBytes(data); // Deterministic random
|
||||
|
||||
var hash1 = SHA256.HashData(data);
|
||||
var hash2 = SHA256.HashData(data);
|
||||
|
||||
@@ -3,8 +3,10 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.Determinism.Properties;
|
||||
|
||||
@@ -25,10 +27,11 @@ public class FloatingPointStabilityProperties
|
||||
|
||||
var obj = new Dictionary<string, double> { ["value"] = value };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"Double {value} should serialize deterministically");
|
||||
}
|
||||
|
||||
@@ -40,10 +43,11 @@ public class FloatingPointStabilityProperties
|
||||
{
|
||||
var obj = new Dictionary<string, decimal> { ["value"] = value };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"Decimal {value} should serialize deterministically");
|
||||
}
|
||||
|
||||
@@ -68,9 +72,9 @@ public class FloatingPointStabilityProperties
|
||||
{
|
||||
var obj = new Dictionary<string, double> { ["value"] = value };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash3 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
var hash3 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
hash2.Should().Be(hash3);
|
||||
@@ -111,10 +115,11 @@ public class FloatingPointStabilityProperties
|
||||
var obj1 = new Dictionary<string, double> { ["value"] = asDouble1 };
|
||||
var obj2 = new Dictionary<string, double> { ["value"] = asDouble2 };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj2);
|
||||
var hash1 = CanonJson.HashPrefixed(obj1);
|
||||
var hash2 = CanonJson.HashPrefixed(obj2);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"Float {value} -> double conversion should be deterministic");
|
||||
}
|
||||
|
||||
@@ -132,6 +137,7 @@ public class FloatingPointStabilityProperties
|
||||
var parsed = doc.RootElement.GetProperty("value").GetInt32();
|
||||
|
||||
return (parsed == value)
|
||||
.ToProperty()
|
||||
.Label($"Integer {value} should serialize and parse exactly");
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@ public class FloatingPointStabilityProperties
|
||||
var parsed = doc.RootElement.GetProperty("value").GetInt64();
|
||||
|
||||
return (parsed == value)
|
||||
.ToProperty()
|
||||
.Label($"Long {value} should serialize without precision loss");
|
||||
}
|
||||
|
||||
@@ -206,8 +213,8 @@ public class FloatingPointStabilityProperties
|
||||
{
|
||||
var obj = new Dictionary<string, double> { ["value"] = value };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
@@ -254,9 +261,9 @@ public class FloatingPointStabilityProperties
|
||||
|
||||
// All should produce the same hash when values are equal
|
||||
// (Note: decimal preserves trailing zeros, so hashes may differ)
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj2);
|
||||
var hash3 = CanonJson.HashSha256Prefixed(obj3);
|
||||
var hash1 = CanonJson.HashPrefixed(obj1);
|
||||
var hash2 = CanonJson.HashPrefixed(obj2);
|
||||
var hash3 = CanonJson.HashPrefixed(obj3);
|
||||
|
||||
// Document the actual behavior
|
||||
if (decimal1 == decimal2 && decimal2 == decimal3)
|
||||
@@ -282,10 +289,11 @@ public class FloatingPointStabilityProperties
|
||||
["cvss"] = cvss
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(vuln);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(vuln);
|
||||
var hash1 = CanonJson.HashPrefixed(vuln);
|
||||
var hash2 = CanonJson.HashPrefixed(vuln);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"CVSS score {cvss} should serialize deterministically");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
|
||||
namespace StellaOps.Testing.Determinism.Properties;
|
||||
|
||||
@@ -12,14 +13,15 @@ public static class JsonObjectArbitraries
|
||||
/// </summary>
|
||||
public static Arbitrary<Dictionary<string, string>> StringDictionary()
|
||||
{
|
||||
return Gen.Sized(size =>
|
||||
return Gen.Sized<Dictionary<string, string>>(size =>
|
||||
{
|
||||
var count = Gen.Choose(0, Math.Min(size, 20));
|
||||
return count.SelectMany(n =>
|
||||
{
|
||||
var keys = Gen.ArrayOf(n, Arb.Generate<NonEmptyString>().Select(s => s.Get))
|
||||
var keyGen = ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
|
||||
var keys = Gen.ArrayOf<string>(keyGen, n)
|
||||
.Select(arr => arr.Distinct().ToArray());
|
||||
var values = Gen.ArrayOf(n, Arb.Generate<NonEmptyString>().Select(s => s.Get));
|
||||
var values = Gen.ArrayOf<string>(keyGen, n);
|
||||
|
||||
return keys.SelectMany(ks =>
|
||||
values.Select(vs =>
|
||||
@@ -40,14 +42,15 @@ public static class JsonObjectArbitraries
|
||||
/// </summary>
|
||||
public static Arbitrary<Dictionary<string, object?>> ObjectDictionary()
|
||||
{
|
||||
return Gen.Sized(size =>
|
||||
return Gen.Sized<Dictionary<string, object?>>(size =>
|
||||
{
|
||||
var count = Gen.Choose(0, Math.Min(size, 15));
|
||||
return count.SelectMany(n =>
|
||||
{
|
||||
var keys = Gen.ArrayOf(n, Arb.Generate<NonEmptyString>().Select(s => s.Get))
|
||||
var keyGen = ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
|
||||
var keys = Gen.ArrayOf<string>(keyGen, n)
|
||||
.Select(arr => arr.Distinct().ToArray());
|
||||
var values = Gen.ArrayOf(n, JsonValueGen());
|
||||
var values = Gen.ArrayOf<object?>(JsonValueGen(), n);
|
||||
|
||||
return keys.SelectMany(ks =>
|
||||
values.Select(vs =>
|
||||
@@ -68,13 +71,13 @@ public static class JsonObjectArbitraries
|
||||
/// </summary>
|
||||
private static Gen<object?> JsonValueGen()
|
||||
{
|
||||
return Gen.OneOf(
|
||||
Arb.Generate<NonEmptyString>().Select(s => (object?)s.Get),
|
||||
Arb.Generate<int>().Select(i => (object?)i),
|
||||
Arb.Generate<double>()
|
||||
return Gen.OneOf<object?>(
|
||||
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => (object?)s.Get),
|
||||
ArbMap.Default.GeneratorFor<int>().Select(i => (object?)i),
|
||||
ArbMap.Default.GeneratorFor<double>()
|
||||
.Where(d => !double.IsNaN(d) && !double.IsInfinity(d))
|
||||
.Select(d => (object?)d),
|
||||
Arb.Generate<bool>().Select(b => (object?)b),
|
||||
ArbMap.Default.GeneratorFor<bool>().Select(b => (object?)b),
|
||||
Gen.Constant<object?>(null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
@@ -51,12 +52,13 @@ public class SbomVexOrderingDeterminismProperties
|
||||
|
||||
// Note: Arrays preserve order, so we need to sort by a key before canonicalization
|
||||
// This test verifies that the canonical form handles this correctly
|
||||
var hash1 = CanonJson.HashSha256Prefixed(sbom1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(sbom2);
|
||||
var hash1 = CanonJson.HashPrefixed(sbom1);
|
||||
var hash2 = CanonJson.HashPrefixed(sbom2);
|
||||
|
||||
// Since arrays preserve order, different orderings WILL produce different hashes
|
||||
// This is expected behavior - the test documents this
|
||||
return true.ToProperty()
|
||||
return true
|
||||
.ToProperty()
|
||||
.Label($"Array order preserved: hash1={hash1}, hash2={hash2}");
|
||||
}
|
||||
|
||||
@@ -91,11 +93,12 @@ public class SbomVexOrderingDeterminismProperties
|
||||
["id"] = cveId.Get
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(vuln1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(vuln2);
|
||||
var hash3 = CanonJson.HashSha256Prefixed(vuln3);
|
||||
var hash1 = CanonJson.HashPrefixed(vuln1);
|
||||
var hash2 = CanonJson.HashPrefixed(vuln2);
|
||||
var hash3 = CanonJson.HashPrefixed(vuln3);
|
||||
|
||||
return (hash1 == hash2 && hash2 == hash3)
|
||||
.ToProperty()
|
||||
.Label($"Vulnerability metadata should produce same hash regardless of property order. Got: {hash1}, {hash2}, {hash3}");
|
||||
}
|
||||
|
||||
@@ -147,10 +150,11 @@ public class SbomVexOrderingDeterminismProperties
|
||||
}
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(statement);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(statement2);
|
||||
var hash1 = CanonJson.HashPrefixed(statement);
|
||||
var hash2 = CanonJson.HashPrefixed(statement2);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"VEX statement should produce same hash. Got: {hash1} vs {hash2}");
|
||||
}
|
||||
|
||||
@@ -170,6 +174,7 @@ public class SbomVexOrderingDeterminismProperties
|
||||
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(purl2));
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label("PURL identifiers should hash consistently");
|
||||
}
|
||||
|
||||
@@ -199,10 +204,11 @@ public class SbomVexOrderingDeterminismProperties
|
||||
["dependencies"] = sorted2
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(depTree1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(depTree2);
|
||||
var hash1 = CanonJson.HashPrefixed(depTree1);
|
||||
var hash2 = CanonJson.HashPrefixed(depTree2);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"Sorted dependency trees should have same hash: {hash1}");
|
||||
}
|
||||
|
||||
@@ -217,10 +223,11 @@ public class SbomVexOrderingDeterminismProperties
|
||||
var vuln1 = new Dictionary<string, string> { ["id"] = cveId };
|
||||
var vuln2 = new Dictionary<string, string> { ["id"] = cveId };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(vuln1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(vuln2);
|
||||
var hash1 = CanonJson.HashPrefixed(vuln1);
|
||||
var hash2 = CanonJson.HashPrefixed(vuln2);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label($"CVE ID {cveId} should hash consistently");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FsCheck" Version="3.0.0-rc3" />
|
||||
<PackageReference Include="FsCheck.Xunit" Version="3.0.0-rc3" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonical.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.Determinism.Properties;
|
||||
|
||||
@@ -25,6 +27,7 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(nfc2));
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label("NFC-normalized strings should hash identically");
|
||||
}
|
||||
|
||||
@@ -42,6 +45,7 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
var nfc2 = nfd.Normalize(NormalizationForm.FormC);
|
||||
|
||||
return (nfc1 == nfc2)
|
||||
.ToProperty()
|
||||
.Label("NFD to NFC conversion should be deterministic");
|
||||
}
|
||||
|
||||
@@ -103,10 +107,11 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
[key.Get.Normalize(NormalizationForm.FormC)] = value.Get.Normalize(NormalizationForm.FormC)
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
return (hash1 == hash2)
|
||||
.ToProperty()
|
||||
.Label("JSON with Unicode should canonicalize consistently");
|
||||
}
|
||||
|
||||
@@ -128,8 +133,8 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
["wave"] = emoji3
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj);
|
||||
var hash1 = CanonJson.HashPrefixed(obj);
|
||||
var hash2 = CanonJson.HashPrefixed(obj);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
@@ -166,6 +171,7 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
var normalized = ascii.Normalize(NormalizationForm.FormC);
|
||||
|
||||
return (ascii == normalized)
|
||||
.ToProperty()
|
||||
.Label("ASCII strings should be unchanged by NFC normalization");
|
||||
}
|
||||
|
||||
@@ -181,8 +187,8 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
var obj1 = new Dictionary<string, string> { ["text"] = withZeroWidth };
|
||||
var obj2 = new Dictionary<string, string> { ["text"] = withZeroWidth };
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(obj1);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(obj2);
|
||||
var hash1 = CanonJson.HashPrefixed(obj1);
|
||||
var hash2 = CanonJson.HashPrefixed(obj2);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
|
||||
@@ -210,8 +216,8 @@ public class UnicodeNormalizationDeterminismProperties
|
||||
["greek"] = "Γειά σου"
|
||||
};
|
||||
|
||||
var hash1 = CanonJson.HashSha256Prefixed(mixedScript);
|
||||
var hash2 = CanonJson.HashSha256Prefixed(mixedScript);
|
||||
var hash1 = CanonJson.HashPrefixed(mixedScript);
|
||||
var hash2 = CanonJson.HashPrefixed(mixedScript);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -7,12 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.2.0" />
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class RunManifestValidator : IRunManifestValidator
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
var json = RunManifestSerializer.Serialize(manifest);
|
||||
var schemaResult = _schema.Evaluate(JsonDocument.Parse(json));
|
||||
var schemaResult = _schema.Evaluate(JsonDocument.Parse(json).RootElement);
|
||||
if (!schemaResult.IsValid && schemaResult.Errors is not null)
|
||||
{
|
||||
foreach (var error in schemaResult.Errors)
|
||||
|
||||
Reference in New Issue
Block a user