Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// AirGapTrustStoreIntegrationTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Description: Unit tests for AirGapTrustStoreIntegration.
|
||||
@@ -337,7 +337,6 @@ public class AirGapTrustStoreIntegrationTests : IDisposable
|
||||
private static string GenerateEcdsaPublicKeyPem()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using StellaOps.TestKit;
|
||||
return ecdsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditPackExportServiceIntegrationTests.cs
|
||||
// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export
|
||||
// Task: T11 — Integration tests for export flow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for AuditPackExportService.
|
||||
/// Tests full export flows including ZIP, JSON, and DSSE formats.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "AuditPack")]
|
||||
public class AuditPackExportServiceIntegrationTests
|
||||
{
|
||||
private readonly AuditPackExportService _service;
|
||||
|
||||
public AuditPackExportServiceIntegrationTests()
|
||||
{
|
||||
var mockWriter = new MockAuditBundleWriter();
|
||||
_service = new AuditPackExportService(mockWriter);
|
||||
}
|
||||
|
||||
#region ZIP Export Tests
|
||||
|
||||
[Fact(DisplayName = "ZIP export produces valid archive with manifest")]
|
||||
public async Task ExportAsZip_ProducesValidArchive()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/zip");
|
||||
result.Filename.Should().EndWith(".zip");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes all requested segments")]
|
||||
public async Task ExportAsZip_IncludesRequestedSegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip, includeAllSegments: true);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
// Verify manifest exists
|
||||
archive.GetEntry("manifest.json").Should().NotBeNull();
|
||||
|
||||
// Verify segment entries exist
|
||||
archive.GetEntry("sbom/sbom.json").Should().NotBeNull();
|
||||
archive.GetEntry("match/vulnerability-match.json").Should().NotBeNull();
|
||||
archive.GetEntry("reachability/reachability-analysis.json").Should().NotBeNull();
|
||||
archive.GetEntry("policy/policy-evaluation.json").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes attestations when requested")]
|
||||
public async Task ExportAsZip_IncludesAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = true,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
// Note: Attestations entry may be empty without repository
|
||||
archive.Entries.Should().Contain(e => e.FullName.Contains("manifest.json"));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP export includes proof chain when requested")]
|
||||
public async Task ExportAsZip_IncludesProofChain()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = true,
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ZIP manifest contains export metadata")]
|
||||
public async Task ExportAsZip_ManifestContainsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Zip);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
using var memoryStream = new MemoryStream(result.Data!);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
var manifestEntry = archive.GetEntry("manifest.json");
|
||||
manifestEntry.Should().NotBeNull();
|
||||
|
||||
using var reader = new StreamReader(manifestEntry!.Open());
|
||||
var manifestJson = await reader.ReadToEndAsync();
|
||||
var manifest = JsonSerializer.Deserialize<ExportManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
manifest.Should().NotBeNull();
|
||||
manifest!.ScanId.Should().Be("scan-123");
|
||||
manifest.Format.Should().Be("Zip");
|
||||
manifest.Version.Should().Be("1.0");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Export Tests
|
||||
|
||||
[Fact(DisplayName = "JSON export produces valid document")]
|
||||
public async Task ExportAsJson_ProducesValidDocument()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/json");
|
||||
result.Filename.Should().EndWith(".json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Verify it's valid JSON
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
doc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
doc.RootElement.GetProperty("format").GetString().Should().Be("json");
|
||||
doc.RootElement.GetProperty("version").GetString().Should().Be("1.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export includes all segments")]
|
||||
public async Task ExportAsJson_IncludesAllSegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json, includeAllSegments: true);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var segments = doc.RootElement.GetProperty("segments");
|
||||
|
||||
segments.GetProperty("sbom").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("match").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("reachability").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
segments.GetProperty("policy").ValueKind.Should().NotBe(JsonValueKind.Undefined);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "JSON export has correct export timestamp")]
|
||||
public async Task ExportAsJson_HasExportTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
var beforeExport = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
var afterExport = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var doc = JsonDocument.Parse(result.Data!);
|
||||
var exportedAt = DateTimeOffset.Parse(doc.RootElement.GetProperty("exportedAt").GetString()!);
|
||||
|
||||
exportedAt.Should().BeOnOrAfter(beforeExport);
|
||||
exportedAt.Should().BeOnOrBefore(afterExport);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DSSE Export Tests
|
||||
|
||||
[Fact(DisplayName = "DSSE export produces valid envelope")]
|
||||
public async Task ExportAsDsse_ProducesValidEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
result.Filename.Should().EndWith(".dsse.json");
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Verify envelope structure
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
envelope.Should().NotBeNull();
|
||||
envelope!.PayloadType.Should().Be("application/vnd.stellaops.audit-pack+json");
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DSSE envelope payload is valid base64")]
|
||||
public async Task ExportAsDsse_PayloadIsValidBase64()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Dsse);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<DsseExportEnvelope>(result.Data!, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
var payloadBytes = Convert.FromBase64String(envelope!.Payload);
|
||||
payloadBytes.Should().NotBeEmpty();
|
||||
|
||||
// Payload should be valid JSON
|
||||
var payloadDoc = JsonDocument.Parse(payloadBytes);
|
||||
payloadDoc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact(DisplayName = "Export returns error for unsupported format")]
|
||||
public async Task Export_ReturnsError_ForUnsupportedFormat()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = (ExportFormat)999, // Invalid format
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Unsupported");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export handles empty segments list")]
|
||||
public async Task Export_HandlesEmptySegments()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = ExportFormat.Zip,
|
||||
Segments = [],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Export includes finding IDs when specified")]
|
||||
public async Task Export_IncludesFindingIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
FindingIds = ["CVE-2024-0001@pkg:npm/lodash@4.17.21", "CVE-2024-0002@pkg:npm/express@4.18.0"],
|
||||
Format = ExportFormat.Json,
|
||||
Segments = [ExportSegment.Sbom],
|
||||
Filename = "test-export"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Size Reporting Tests
|
||||
|
||||
[Fact(DisplayName = "Export reports correct size")]
|
||||
public async Task Export_ReportsCorrectSize()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(ExportFormat.Json);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExportAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.SizeBytes.Should().Be(result.Data!.Length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ExportRequest CreateTestRequest(
|
||||
ExportFormat format,
|
||||
bool includeAllSegments = false)
|
||||
{
|
||||
var segments = includeAllSegments
|
||||
? new List<ExportSegment>
|
||||
{
|
||||
ExportSegment.Sbom,
|
||||
ExportSegment.Match,
|
||||
ExportSegment.Reachability,
|
||||
ExportSegment.Guards,
|
||||
ExportSegment.Runtime,
|
||||
ExportSegment.Policy
|
||||
}
|
||||
: new List<ExportSegment> { ExportSegment.Sbom, ExportSegment.Match };
|
||||
|
||||
return new ExportRequest
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
Format = format,
|
||||
Segments = segments,
|
||||
IncludeAttestations = false,
|
||||
IncludeProofChain = false,
|
||||
Filename = "test-export"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IAuditBundleWriter for testing.
|
||||
/// </summary>
|
||||
internal class MockAuditBundleWriter : Services.IAuditBundleWriter
|
||||
{
|
||||
public Task<Services.AuditBundleWriteResult> WriteAsync(Services.AuditBundleWriteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new Services.AuditBundleWriteResult
|
||||
{
|
||||
Success = true,
|
||||
BundleId = "test-bundle",
|
||||
MerkleRoot = "sha256:test",
|
||||
BundleDigest = "sha256:test",
|
||||
TotalSizeBytes = 0,
|
||||
FileCount = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditReplayE2ETests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Task: REPLAY-028 - E2E test: export -> transfer -> replay offline
|
||||
@@ -513,7 +513,6 @@ public class AuditReplayE2ETests : IDisposable
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
using StellaOps.TestKit;
|
||||
var hash = await SHA256.HashDataAsync(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -7,21 +7,8 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,5 @@
|
||||
using FsCheck;
|
||||
using FsCheck.Fluent;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Canonicalization.Ordering;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,17 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FsCheck.Xunit" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -99,13 +99,14 @@ public sealed class CloudKmsClientTests
|
||||
public void KmsCryptoProvider_Skips_NonExportable_Keys()
|
||||
{
|
||||
using var fixture = new EcdsaFixture();
|
||||
var kmsClient = new NonExportingKmsClient(fixture.Parameters);
|
||||
var parameters = fixture.Parameters;
|
||||
var kmsClient = new NonExportingKmsClient(parameters);
|
||||
var provider = new KmsCryptoProvider(kmsClient);
|
||||
|
||||
var signingKey = new CryptoSigningKey(
|
||||
new CryptoKeyReference("arn:aws:kms:us-east-1:123456789012:key/demo", "kms"),
|
||||
KmsAlgorithms.Es256,
|
||||
in fixture.Parameters,
|
||||
in parameters,
|
||||
DateTimeOffset.UtcNow,
|
||||
metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -161,7 +162,6 @@ public sealed class CloudKmsClientTests
|
||||
public async Task Fido2Client_Signs_Verifies_And_Exports()
|
||||
{
|
||||
using var fixture = new EcdsaFixture();
|
||||
using StellaOps.TestKit;
|
||||
var authenticator = new TestFidoAuthenticator(fixture);
|
||||
var options = new Fido2Options
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ public sealed class FileKmsClientTests : IDisposable
|
||||
public async Task ExportAsync_ReturnsKeyMaterial()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using StellaOps.TestKit;
|
||||
var keyId = "kms-export";
|
||||
|
||||
await client.RotateAsync(keyId);
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Plugin.OfflineVerification;
|
||||
using StellaOps.TestKit;
|
||||
using System.Security.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
@@ -260,7 +261,7 @@ public class OfflineVerificationProviderTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[Theory]
|
||||
[InlineData("ES256")]
|
||||
[InlineData("PS256")]
|
||||
public void EphemeralVerifier_HasCorrectProperties(string algorithmId)
|
||||
@@ -270,7 +271,6 @@ public class OfflineVerificationProviderTests
|
||||
using (var ecdsa = ECDsa.Create())
|
||||
{
|
||||
publicKeyBytes = ecdsa.ExportSubjectPublicKeyInfo();
|
||||
using StellaOps.TestKit;
|
||||
}
|
||||
|
||||
// Act
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,4 +19,4 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -11,7 +11,6 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
@@ -22,7 +22,6 @@ public sealed class BouncyCastleEd25519CryptoProviderTests
|
||||
services.AddBouncyCastleEd25519Provider();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using StellaOps.TestKit;
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
var bcProvider = provider.GetServices<ICryptoProvider>()
|
||||
.OfType<BouncyCastleEd25519CryptoProvider>()
|
||||
|
||||
@@ -11,7 +11,6 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
// Description: Capability detection tests for CryptoPro GOST crypto plugin
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Runtime.Versioning;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace StellaOps.Cryptography.Tests;
|
||||
[Trait("Category", "CryptoPro")]
|
||||
[Trait("Category", "GOST")]
|
||||
[Trait("Category", "C1")]
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class CryptoProCapabilityDetectionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if STELLAOPS_CRYPTO_PRO
|
||||
#if STELLAOPS_CRYPTO_PRO
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
@@ -31,7 +31,6 @@ public class CryptoProGostSignerTests
|
||||
var request = new CertificateRequest("CN=stellaops.test", ecdsa, HashAlgorithmName.SHA256);
|
||||
using var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var entry = new CryptoProGostKeyEntry(
|
||||
"test-key",
|
||||
SignatureAlgorithms.GostR3410_2012_256,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -84,7 +84,6 @@ public sealed class DefaultCryptoHashTests
|
||||
var hash = CryptoHashFactory.CreateDefault();
|
||||
var expected = Convert.ToHexStringLower(SHA256.HashData(Sample));
|
||||
await using var stream = new MemoryStream(Sample);
|
||||
using StellaOps.TestKit;
|
||||
var actual = await hash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -32,7 +32,6 @@ public sealed class DefaultCryptoHmacTests
|
||||
var hmac = DefaultCryptoHmac.CreateForTests();
|
||||
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Key, Sample));
|
||||
await using var stream = new MemoryStream(Sample);
|
||||
using StellaOps.TestKit;
|
||||
var actual = await hmac.ComputeHmacHexForPurposeAsync(Key, stream, HmacPurpose.WebhookInterop);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -61,7 +61,6 @@ public class DefaultCryptoProviderSigningTests
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using StellaOps.TestKit;
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var signingKey = new CryptoSigningKey(new CryptoKeyReference("key-to-remove"), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ using StellaOps.Cryptography.Plugin.EIDAS;
|
||||
using StellaOps.Cryptography.Plugin.EIDAS.Configuration;
|
||||
using StellaOps.Cryptography.Plugin.EIDAS.Models;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
@@ -119,7 +118,7 @@ public sealed class KmsHsmConnectorTests
|
||||
{
|
||||
var provider = CreateProvider(new MockKmsClient());
|
||||
var keyReference = new CryptoKeyReference("kms-key-001", provider.Name);
|
||||
var metadata = new Dictionary<string, string?> { [KmsMetadataKeys.Version] = "v1" };
|
||||
var metadata = new Dictionary<string, string?> { ["kms.version"] = "v1" };
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
KmsAlgorithms.Es256,
|
||||
@@ -158,7 +157,7 @@ public sealed class KmsHsmConnectorTests
|
||||
{
|
||||
var provider = CreateProvider(new MockKmsClient());
|
||||
var keyReference = new CryptoKeyReference("kms-key-001", provider.Name);
|
||||
var metadata = new Dictionary<string, string?> { [KmsMetadataKeys.Version] = "v1" };
|
||||
var metadata = new Dictionary<string, string?> { ["kms.version"] = "v1" };
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
@@ -231,7 +230,7 @@ public sealed class KmsHsmConnectorTests
|
||||
var signer = provider.GetSigner(KmsAlgorithms.Es256, keyReference);
|
||||
|
||||
signer.Should().NotBeNull();
|
||||
signer.Should().BeOfType<KmsSigner>();
|
||||
signer.Should().BeAssignableTo<ICryptoSigner>();
|
||||
|
||||
_output.WriteLine("✓ GetSigner returns KmsSigner");
|
||||
}
|
||||
@@ -401,7 +400,7 @@ public sealed class KmsHsmConnectorTests
|
||||
private static void RegisterKey(KmsCryptoProvider provider, string keyId)
|
||||
{
|
||||
var keyReference = new CryptoKeyReference(keyId, provider.Name);
|
||||
var metadata = new Dictionary<string, string?> { [KmsMetadataKeys.Version] = "v1" };
|
||||
var metadata = new Dictionary<string, string?> { ["kms.version"] = "v1" };
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
KmsAlgorithms.Es256,
|
||||
@@ -432,7 +431,7 @@ public sealed class KmsHsmConnectorTests
|
||||
var signatureKey = $"{keyId}:{keyVersion}:{Convert.ToBase64String(data.ToArray())}";
|
||||
_signatures[signatureKey] = signature;
|
||||
|
||||
return Task.FromResult(new KmsSignResult(keyId, keyVersion ?? "v1", signature));
|
||||
return Task.FromResult(new KmsSignResult(keyId, keyVersion ?? "v1", KmsAlgorithms.Es256, signature));
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(
|
||||
@@ -448,12 +447,20 @@ public sealed class KmsHsmConnectorTests
|
||||
|
||||
public Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = System.Collections.Immutable.ImmutableArray.Create(
|
||||
new KmsKeyVersionMetadata(
|
||||
"v1",
|
||||
KmsKeyState.Active,
|
||||
DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
null,
|
||||
string.Empty,
|
||||
"P-256"));
|
||||
var metadata = new KmsKeyMetadata(
|
||||
keyId,
|
||||
"v1",
|
||||
KmsAlgorithms.Es256,
|
||||
KmsKeyState.Active,
|
||||
DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
null);
|
||||
versions);
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
|
||||
@@ -466,20 +473,31 @@ public sealed class KmsHsmConnectorTests
|
||||
var material = new KmsKeyMaterial(
|
||||
keyId,
|
||||
keyVersion ?? "v1",
|
||||
X: new byte[32],
|
||||
Y: new byte[32],
|
||||
D: Array.Empty<byte>());
|
||||
KmsAlgorithms.Es256,
|
||||
"P-256",
|
||||
D: Array.Empty<byte>(),
|
||||
Qx: new byte[32],
|
||||
Qy: new byte[32],
|
||||
DateTimeOffset.UtcNow.AddMonths(-1));
|
||||
return Task.FromResult(material);
|
||||
}
|
||||
|
||||
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = System.Collections.Immutable.ImmutableArray.Create(
|
||||
new KmsKeyVersionMetadata(
|
||||
"v2",
|
||||
KmsKeyState.Active,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
string.Empty,
|
||||
"P-256"));
|
||||
var metadata = new KmsKeyMetadata(
|
||||
keyId,
|
||||
"v2",
|
||||
KmsAlgorithms.Es256,
|
||||
KmsKeyState.Active,
|
||||
DateTimeOffset.UtcNow,
|
||||
null);
|
||||
versions);
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if STELLAOPS_CRYPTO_SODIUM
|
||||
#if STELLAOPS_CRYPTO_SODIUM
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -17,7 +17,6 @@ public class LibsodiumCryptoProviderTests
|
||||
{
|
||||
var provider = new LibsodiumCryptoProvider();
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using StellaOps.TestKit;
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
|
||||
var signingKey = new CryptoSigningKey(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if STELLAOPS_PKCS11
|
||||
#if STELLAOPS_PKCS11
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
@@ -23,7 +23,6 @@ public class Pkcs11GostProviderTests
|
||||
}
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using StellaOps.TestKit;
|
||||
var req = new CertificateRequest("CN=pkcs11.test", ecdsa, HashAlgorithmName.SHA256);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography.Plugin.SimRemote;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
@@ -388,7 +387,7 @@ public sealed class SimRemoteCapabilityDetectionTests
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
key.ProviderName.Should().Be("sim.crypto.remote");
|
||||
key.Provider.Should().Be("sim.crypto.remote");
|
||||
key.Metadata.Should().ContainKey("simulation");
|
||||
key.Metadata["simulation"].Should().Be("true");
|
||||
}
|
||||
@@ -459,8 +458,7 @@ public sealed class SimRemoteCapabilityDetectionTests
|
||||
private static SimRemoteHttpClient CreateMockHttpClient()
|
||||
{
|
||||
var httpClient = new HttpClient();
|
||||
var options = Options.Create(new SimRemoteProviderOptions());
|
||||
return new SimRemoteHttpClient(httpClient, options);
|
||||
return new SimRemoteHttpClient(httpClient);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<Using Include="Xunit.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -20,7 +24,11 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.EIDAS\StellaOps.Cryptography.Plugin.EIDAS.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -6,16 +6,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -12,12 +12,11 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
|
||||
using StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Evidence.Storage.Postgres.Tests;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for cross-module evidence linking.
|
||||
@@ -0,0 +1,71 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidencePostgresContainerFixture.cs
|
||||
// Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model
|
||||
// Description: PostgreSQL test fixture for Evidence persistence tests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Evidence.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Evidence module.
|
||||
/// Runs migrations from embedded resources and provides test isolation via schema truncation.
|
||||
/// </summary>
|
||||
public sealed class EvidencePostgresContainerFixture : PostgresIntegrationFixture, ICollectionFixture<EvidencePostgresContainerFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(EvidenceDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Evidence";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PostgresEvidenceStore for the specified tenant.
|
||||
/// </summary>
|
||||
public PostgresEvidenceStore CreateStore(string tenantId)
|
||||
{
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
SchemaName = SchemaName
|
||||
});
|
||||
|
||||
var dataSource = new EvidenceDataSource(options, NullLogger<EvidenceDataSource>.Instance);
|
||||
return new PostgresEvidenceStore(
|
||||
dataSource,
|
||||
tenantId,
|
||||
NullLogger<PostgresEvidenceStore>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PostgresEvidenceStoreFactory for multi-tenant tests.
|
||||
/// </summary>
|
||||
public PostgresEvidenceStoreFactory CreateStoreFactory()
|
||||
{
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
SchemaName = SchemaName
|
||||
});
|
||||
|
||||
var dataSource = new EvidenceDataSource(options, NullLogger<EvidenceDataSource>.Instance);
|
||||
return new PostgresEvidenceStoreFactory(dataSource, NullLoggerFactory.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// xUnit test collection definition for Evidence PostgreSQL tests.
|
||||
/// All tests in this collection share the same database container.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class EvidencePostgresTestCollection : ICollectionFixture<EvidencePostgresContainerFixture>
|
||||
{
|
||||
public const string Name = "EvidencePostgres";
|
||||
}
|
||||
@@ -8,12 +8,11 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
|
||||
using StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Evidence.Storage.Postgres.Tests;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresEvidenceStore CRUD operations.
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Evidence.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence.Persistence\StellaOps.Evidence.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,185 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidencePostgresContainerFixture.cs
|
||||
// Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model
|
||||
// Task: EVID-8100-017 - PostgreSQL store integration tests
|
||||
// Description: Collection fixture providing a shared PostgreSQL container for Evidence storage tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture that provides a shared PostgreSQL container for Evidence storage integration tests.
|
||||
/// Uses Testcontainers to spin up a PostgreSQL instance with the evidence schema.
|
||||
/// </summary>
|
||||
public sealed class EvidencePostgresContainerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private PostgresFixture? _fixture;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the container is running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _container is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL container.
|
||||
/// </summary>
|
||||
public string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not started");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL fixture for test schema management.
|
||||
/// </summary>
|
||||
public PostgresFixture Fixture => _fixture
|
||||
?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Creates PostgreSQL options configured for the test container.
|
||||
/// </summary>
|
||||
public PostgresOptions CreateOptions()
|
||||
{
|
||||
return new PostgresOptions
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
SchemaName = EvidenceDataSource.DefaultSchemaName,
|
||||
CommandTimeoutSeconds = 30,
|
||||
AutoMigrate = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an EvidenceDataSource for tests.
|
||||
/// </summary>
|
||||
public EvidenceDataSource CreateDataSource()
|
||||
{
|
||||
var options = Options.Create(CreateOptions());
|
||||
return new EvidenceDataSource(options, NullLogger<EvidenceDataSource>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PostgresEvidenceStore for the specified tenant.
|
||||
/// </summary>
|
||||
public PostgresEvidenceStore CreateStore(string tenantId)
|
||||
{
|
||||
var dataSource = CreateDataSource();
|
||||
return new PostgresEvidenceStore(
|
||||
dataSource,
|
||||
tenantId,
|
||||
NullLogger<PostgresEvidenceStore>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PostgresEvidenceStoreFactory for tests.
|
||||
/// </summary>
|
||||
public PostgresEvidenceStoreFactory CreateStoreFactory()
|
||||
{
|
||||
var dataSource = CreateDataSource();
|
||||
return new PostgresEvidenceStoreFactory(dataSource, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("stellaops_test")
|
||||
.WithUsername("test")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
// Create fixture for schema management
|
||||
_fixture = PostgresFixtureFactory.CreateRandom(ConnectionString);
|
||||
await _fixture.InitializeAsync();
|
||||
|
||||
// Run evidence schema migrations
|
||||
await _fixture.RunMigrationsFromAssemblyAsync<EvidenceDataSource>(
|
||||
"Evidence",
|
||||
resourcePrefix: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fixture is not null)
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures during skip.
|
||||
}
|
||||
|
||||
_container = null;
|
||||
_fixture = null;
|
||||
|
||||
throw SkipException.ForSkip(
|
||||
$"Evidence PostgreSQL integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task DisposeAsyncCore()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_fixture is not null)
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all tables for test isolation.
|
||||
/// </summary>
|
||||
public async Task TruncateAllTablesAsync()
|
||||
{
|
||||
if (_fixture is null) return;
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Evidence PostgreSQL integration tests.
|
||||
/// All tests in this collection share a single PostgreSQL container.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class EvidencePostgresTestCollection : ICollectionFixture<EvidencePostgresContainerFixture>
|
||||
{
|
||||
public const string Name = "Evidence PostgreSQL Integration Tests";
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Evidence.Storage.Postgres.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence.Storage.Postgres\StellaOps.Evidence.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,5 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Nulls Logger;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,16 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -373,7 +373,7 @@ public sealed class StartupMigrationHostTests : IAsyncLifetime
|
||||
|
||||
reader.GetString(0).Should().Be("startup"); // category
|
||||
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
|
||||
reader.GetInt32(2).Should().BeGreaterOrEqualTo(0); // duration_ms
|
||||
reader.GetInt32(2).Should().BeGreaterThanOrEqualTo(0); // duration_ms
|
||||
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ public sealed class PostgresFixtureTests : IAsyncLifetime
|
||||
await using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
|
||||
conn);
|
||||
using StellaOps.TestKit;
|
||||
cmd.Parameters.AddWithValue("name", schemaName);
|
||||
var exists = await cmd.ExecuteScalarAsync();
|
||||
exists.Should().Be(false);
|
||||
|
||||
@@ -11,24 +11,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,667 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AtLeastOnceDeliveryTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-009 - At-least-once delivery with consumer idempotency
|
||||
// Description: Integration tests verifying at-least-once delivery semantics:
|
||||
// - Messages are never lost (guaranteed delivery)
|
||||
// - Consumer idempotency correctly handles duplicate deliveries
|
||||
// - Lease expiration triggers redelivery
|
||||
// - Simulated failures result in message redelivery
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for at-least-once delivery semantics with consumer idempotency.
|
||||
///
|
||||
/// At-least-once delivery guarantees:
|
||||
/// 1. Every message sent is delivered at least once
|
||||
/// 2. Messages may be delivered multiple times (redelivery on failure)
|
||||
/// 3. Consumer idempotency handles duplicate deliveries
|
||||
/// 4. No message is ever lost, even under failure conditions
|
||||
/// </summary>
|
||||
[Collection(ValkeyIntegrationTestCollection.Name)]
|
||||
public sealed class AtLeastOnceDeliveryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ValkeyContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ValkeyConnectionFactory? _connectionFactory;
|
||||
private ValkeyIdempotencyStore? _idempotencyStore;
|
||||
|
||||
public AtLeastOnceDeliveryTests(ValkeyContainerFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_connectionFactory = _fixture.CreateConnectionFactory();
|
||||
_idempotencyStore = new ValkeyIdempotencyStore(
|
||||
_connectionFactory,
|
||||
$"test-consumer-{Guid.NewGuid():N}",
|
||||
null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connectionFactory is not null)
|
||||
{
|
||||
await _connectionFactory.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region At-Least-Once Delivery Guarantee Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_MessageSent_IsDeliveredAtLeastOnce()
|
||||
{
|
||||
// Arrange - Producer sends message
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var messageId = Guid.NewGuid();
|
||||
var message = new TestMessage
|
||||
{
|
||||
Id = messageId,
|
||||
Content = "At-least-once test message"
|
||||
};
|
||||
|
||||
// Act - Send message
|
||||
var enqueueResult = await queue.EnqueueAsync(message);
|
||||
enqueueResult.Success.Should().BeTrue("message should be accepted by the queue");
|
||||
|
||||
// Act - Consumer receives message
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert - Message is delivered
|
||||
leases.Should().HaveCount(1, "message must be delivered at least once");
|
||||
leases[0].Message.Id.Should().Be(messageId);
|
||||
leases[0].Message.Content.Should().Be("At-least-once test message");
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} delivered successfully");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_UnacknowledgedLease_MessageRedelivered()
|
||||
{
|
||||
// Arrange - Create queue with short lease duration
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Redelivery test" });
|
||||
|
||||
// Act - Lease message but don't acknowledge (simulating consumer crash)
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
firstLease[0].Message.Id.Should().Be(messageId);
|
||||
|
||||
// Don't acknowledge - simulate crash
|
||||
_output.WriteLine("Simulating consumer crash (not acknowledging message)");
|
||||
|
||||
// Wait for lease to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - Claim expired message (automatic redelivery)
|
||||
var redelivered = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 10,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(200),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
// Assert - Message is redelivered
|
||||
redelivered.Should().HaveCount(1, "message must be redelivered after lease expiration");
|
||||
redelivered[0].Message.Id.Should().Be(messageId);
|
||||
redelivered[0].Attempt.Should().BeGreaterThan(1, "this should be a redelivery");
|
||||
|
||||
await redelivered[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} successfully redelivered on attempt {redelivered[0].Attempt}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_MultipleMessages_AllDelivered()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
const int messageCount = 100;
|
||||
var sentIds = new HashSet<Guid>();
|
||||
|
||||
// Act - Send multiple messages
|
||||
for (int i = 0; i < messageCount; i++)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
sentIds.Add(id);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = id, Content = $"Message-{i}" });
|
||||
}
|
||||
|
||||
// Act - Receive all messages
|
||||
var receivedIds = new HashSet<Guid>();
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 20 });
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
receivedIds.Add(lease.Message.Id);
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - All messages delivered
|
||||
receivedIds.Should().BeEquivalentTo(sentIds, "all sent messages must be delivered");
|
||||
_output.WriteLine($"All {messageCount} messages delivered successfully");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task AtLeastOnce_RetryAfterNack_MessageRedelivered()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.RetryInitialBackoff = TimeSpan.Zero; // Immediate retry for test speed
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Retry test" });
|
||||
|
||||
// Act - First delivery, simulate processing failure with retry
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
firstLease[0].Attempt.Should().Be(1);
|
||||
|
||||
// Nack for retry
|
||||
await firstLease[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
_output.WriteLine("Message nacked for retry");
|
||||
|
||||
// Brief delay for retry processing
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - Second delivery after retry
|
||||
var secondLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert - Message is redelivered
|
||||
secondLease.Should().HaveCount(1, "message must be redelivered after nack");
|
||||
secondLease[0].Message.Id.Should().Be(messageId);
|
||||
secondLease[0].Attempt.Should().Be(2, "this should be attempt 2");
|
||||
|
||||
await secondLease[0].AcknowledgeAsync();
|
||||
_output.WriteLine($"Message {messageId} successfully processed on attempt 2");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumer Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_DuplicateProcessing_DetectedAndSkipped()
|
||||
{
|
||||
// Arrange - Create a consumer with idempotency tracking
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var processedMessageIds = new HashSet<Guid>();
|
||||
var processingCount = new Dictionary<Guid, int>();
|
||||
|
||||
var messageId = Guid.NewGuid();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "Idempotency test" });
|
||||
|
||||
// Act - Simulate receiving the message multiple times
|
||||
for (int delivery = 1; delivery <= 3; delivery++)
|
||||
{
|
||||
// Simulate message delivery (could be redelivery)
|
||||
var idempotencyKey = $"consumer-process:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
messageId.ToString(),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
// First time processing this message
|
||||
processedMessageIds.Add(messageId);
|
||||
processingCount[messageId] = 1;
|
||||
_output.WriteLine($"Delivery {delivery}: First processing of message {messageId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Duplicate - skip processing
|
||||
processingCount[messageId] = processingCount.GetValueOrDefault(messageId) + 1;
|
||||
_output.WriteLine($"Delivery {delivery}: Duplicate detected, skipping message {messageId}");
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Message processed exactly once despite multiple deliveries
|
||||
processedMessageIds.Should().HaveCount(1);
|
||||
processingCount[messageId].Should().BeGreaterThan(1, "we simulated multiple deliveries");
|
||||
|
||||
// Cleanup
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
if (leases.Count > 0)
|
||||
{
|
||||
await leases[0].AcknowledgeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_ConcurrentDuplicates_OnlyOneProcessed()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var processedCount = 0;
|
||||
var duplicateCount = 0;
|
||||
var lockObject = new object();
|
||||
|
||||
// Simulate 10 concurrent consumers trying to process the same message
|
||||
var tasks = Enumerable.Range(1, 10).Select(async consumerId =>
|
||||
{
|
||||
var idempotencyKey = $"concurrent-test:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
$"consumer-{consumerId}",
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
lock (lockObject)
|
||||
{
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
processedCount++;
|
||||
_output.WriteLine($"Consumer {consumerId}: Processing message (first claim)");
|
||||
}
|
||||
else
|
||||
{
|
||||
duplicateCount++;
|
||||
_output.WriteLine($"Consumer {consumerId}: Duplicate detected, existing value: {claimResult.ExistingValue}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Exactly one consumer processed the message
|
||||
processedCount.Should().Be(1, "only one consumer should process the message");
|
||||
duplicateCount.Should().Be(9, "9 consumers should detect duplicate");
|
||||
_output.WriteLine($"Processed: {processedCount}, Duplicates: {duplicateCount}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_IdempotencyWindowExpires_ReprocessingAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var shortWindow = TimeSpan.FromMilliseconds(200);
|
||||
var idempotencyKey = $"window-test:{messageId}";
|
||||
|
||||
// Act - First claim
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"first-processor",
|
||||
shortWindow);
|
||||
firstClaim.IsFirstClaim.Should().BeTrue();
|
||||
_output.WriteLine("First claim successful");
|
||||
|
||||
// Duplicate should be detected
|
||||
var duplicateClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"duplicate-processor",
|
||||
shortWindow);
|
||||
duplicateClaim.IsDuplicate.Should().BeTrue();
|
||||
_output.WriteLine("Duplicate detected as expected");
|
||||
|
||||
// Wait for window to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - After expiration, claim should succeed again
|
||||
var afterExpiration = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-processor",
|
||||
shortWindow);
|
||||
|
||||
// Assert - Reprocessing allowed after window expiration
|
||||
afterExpiration.IsFirstClaim.Should().BeTrue(
|
||||
"after idempotency window expires, message can be reprocessed");
|
||||
_output.WriteLine("After window expiration, new claim succeeded");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerIdempotency_DifferentMessages_IndependentProcessing()
|
||||
{
|
||||
// Arrange - Three different messages
|
||||
var messageIds = Enumerable.Range(1, 3).Select(_ => Guid.NewGuid()).ToList();
|
||||
var processedIds = new List<Guid>();
|
||||
|
||||
// Act - Process each message (simulating first-time delivery)
|
||||
foreach (var messageId in messageIds)
|
||||
{
|
||||
var idempotencyKey = $"different-msg-test:{messageId}";
|
||||
var claimResult = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
messageId.ToString(),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claimResult.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - All different messages processed
|
||||
processedIds.Should().BeEquivalentTo(messageIds);
|
||||
_output.WriteLine($"All {messageIds.Count} different messages processed independently");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End At-Least-Once with Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EndToEnd_AtLeastOnceWithIdempotency_NoDuplicateProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(200);
|
||||
var queue = CreateQueue<TestMessage>(queueOptions);
|
||||
|
||||
var messageId = Guid.NewGuid();
|
||||
var processedIds = new HashSet<Guid>();
|
||||
var deliveryCount = 0;
|
||||
|
||||
await queue.EnqueueAsync(new TestMessage { Id = messageId, Content = "E2E test" });
|
||||
|
||||
// Act - Consumer with idempotency-aware processing
|
||||
// Simulate: first delivery - lease but crash, second delivery - process successfully
|
||||
|
||||
// First delivery (crash simulation - don't ack)
|
||||
var firstLease = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
firstLease.Should().HaveCount(1);
|
||||
deliveryCount++;
|
||||
|
||||
// Attempt to claim for processing
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"e2e-test:{firstLease[0].Message.Id}",
|
||||
firstLease[0].MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (firstClaim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(firstLease[0].Message.Id);
|
||||
}
|
||||
|
||||
// Simulate crash - don't acknowledge
|
||||
_output.WriteLine("First delivery: Processing started but consumer crashed");
|
||||
|
||||
// Wait for lease expiration
|
||||
await Task.Delay(500);
|
||||
|
||||
// Claim expired message (redelivery)
|
||||
var redelivered = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 1,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(200),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
if (redelivered.Count > 0)
|
||||
{
|
||||
deliveryCount++;
|
||||
|
||||
// Attempt to claim again (should be duplicate)
|
||||
var secondClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"e2e-test:{redelivered[0].Message.Id}",
|
||||
redelivered[0].MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (secondClaim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(redelivered[0].Message.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_output.WriteLine($"Second delivery: Duplicate detected, skipping processing");
|
||||
}
|
||||
|
||||
// This time, acknowledge
|
||||
await redelivered[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Second delivery: Message acknowledged");
|
||||
}
|
||||
|
||||
// Assert
|
||||
processedIds.Should().HaveCount(1, "message should be processed exactly once");
|
||||
deliveryCount.Should().BeGreaterThan(1, "message should be delivered at least twice (crash + redelivery)");
|
||||
_output.WriteLine($"Total deliveries: {deliveryCount}, Unique processing: {processedIds.Count}");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EndToEnd_BulkMessages_AtLeastOnceWithIdempotency()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
const int messageCount = 50;
|
||||
var processedIds = new ConcurrentHashSet<Guid>();
|
||||
var deliveryAttempts = new Dictionary<Guid, int>();
|
||||
|
||||
// Send messages
|
||||
var sentIds = new List<Guid>();
|
||||
for (int i = 0; i < messageCount; i++)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
sentIds.Add(id);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = id, Content = $"Bulk-{i}" });
|
||||
}
|
||||
|
||||
// Act - Process all messages with idempotency
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
if (leases.Count == 0) break;
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
var msgId = lease.Message.Id;
|
||||
deliveryAttempts[msgId] = deliveryAttempts.GetValueOrDefault(msgId) + 1;
|
||||
|
||||
// Check idempotency before processing
|
||||
var claim = await _idempotencyStore!.TryClaimAsync(
|
||||
$"bulk-test:{msgId}",
|
||||
lease.MessageId,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
if (claim.IsFirstClaim)
|
||||
{
|
||||
processedIds.Add(msgId);
|
||||
}
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - All messages processed exactly once
|
||||
processedIds.Count.Should().Be(messageCount, "all messages should be processed");
|
||||
sentIds.Should().BeEquivalentTo(processedIds.ToList(), "all sent messages should be processed");
|
||||
_output.WriteLine($"Processed {processedIds.Count}/{messageCount} messages with idempotency");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_ExtendWindow()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"extend-test:{messageId}";
|
||||
var shortWindow = TimeSpan.FromSeconds(1);
|
||||
|
||||
// Act - Claim with short window
|
||||
var claim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"original-value",
|
||||
shortWindow);
|
||||
claim.IsFirstClaim.Should().BeTrue();
|
||||
|
||||
// Extend the window
|
||||
var extended = await _idempotencyStore!.ExtendAsync(
|
||||
idempotencyKey,
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
// Assert - Window extended
|
||||
extended.Should().BeTrue();
|
||||
|
||||
// Duplicate should still be detected after original window would have expired
|
||||
await Task.Delay(1500);
|
||||
var afterOriginalExpiry = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-value",
|
||||
shortWindow);
|
||||
|
||||
afterOriginalExpiry.IsDuplicate.Should().BeTrue(
|
||||
"window was extended, so duplicate should still be detected");
|
||||
_output.WriteLine("Window extension verified - duplicate detected after original expiry");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Release()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"release-test:{messageId}";
|
||||
|
||||
// Claim the key
|
||||
var firstClaim = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"first-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
firstClaim.IsFirstClaim.Should().BeTrue();
|
||||
|
||||
// Duplicate should be detected
|
||||
var duplicate = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"duplicate-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
duplicate.IsDuplicate.Should().BeTrue();
|
||||
|
||||
// Act - Release the key
|
||||
var released = await _idempotencyStore!.ReleaseAsync(idempotencyKey);
|
||||
released.Should().BeTrue();
|
||||
|
||||
// Assert - After release, key can be claimed again
|
||||
var afterRelease = await _idempotencyStore!.TryClaimAsync(
|
||||
idempotencyKey,
|
||||
"new-value",
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
afterRelease.IsFirstClaim.Should().BeTrue(
|
||||
"after release, key should be claimable again");
|
||||
_output.WriteLine("Release verified - key claimable after release");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Exists()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"exists-test:{messageId}";
|
||||
|
||||
// Act - Check before claiming
|
||||
var existsBefore = await _idempotencyStore!.ExistsAsync(idempotencyKey);
|
||||
existsBefore.Should().BeFalse();
|
||||
|
||||
// Claim
|
||||
await _idempotencyStore!.TryClaimAsync(idempotencyKey, "value", TimeSpan.FromMinutes(5));
|
||||
|
||||
// Check after claiming
|
||||
var existsAfter = await _idempotencyStore!.ExistsAsync(idempotencyKey);
|
||||
existsAfter.Should().BeTrue();
|
||||
|
||||
_output.WriteLine("Exists check verified");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task EdgeCase_IdempotencyStore_Get()
|
||||
{
|
||||
// Arrange
|
||||
var messageId = Guid.NewGuid();
|
||||
var idempotencyKey = $"get-test:{messageId}";
|
||||
var storedValue = "stored-processor-id";
|
||||
|
||||
// Act - Get before claiming
|
||||
var valueBefore = await _idempotencyStore!.GetAsync(idempotencyKey);
|
||||
valueBefore.Should().BeNull();
|
||||
|
||||
// Claim
|
||||
await _idempotencyStore!.TryClaimAsync(idempotencyKey, storedValue, TimeSpan.FromMinutes(5));
|
||||
|
||||
// Get after claiming
|
||||
var valueAfter = await _idempotencyStore!.GetAsync(idempotencyKey);
|
||||
|
||||
// Assert
|
||||
valueAfter.Should().Be(storedValue);
|
||||
_output.WriteLine($"Get verified - stored value: {valueAfter}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ValkeyMessageQueue<TMessage> CreateQueue<TMessage>(
|
||||
MessageQueueOptions? queueOptions = null)
|
||||
where TMessage : class
|
||||
{
|
||||
queueOptions ??= _fixture.CreateQueueOptions();
|
||||
var transportOptions = _fixture.CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory!,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
_fixture.GetLogger<ValkeyMessageQueue<TMessage>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Types
|
||||
|
||||
public sealed class TestMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe hash set for concurrent test scenarios.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentHashSet<T> where T : notnull
|
||||
{
|
||||
private readonly HashSet<T> _set = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public bool Add(T item)
|
||||
{
|
||||
lock (_lock) return _set.Add(item);
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) return _set.Count; }
|
||||
}
|
||||
|
||||
public List<T> ToList()
|
||||
{
|
||||
lock (_lock) return _set.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyContainerFixture.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Collection fixture providing a shared Valkey container for integration tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Testing.Fixtures;
|
||||
using Testcontainers.Redis;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture that provides a shared Valkey container for integration tests.
|
||||
/// Uses Redis container (Valkey is Redis-compatible).
|
||||
/// Implements IAsyncLifetime to start/stop the container with the test collection.
|
||||
/// </summary>
|
||||
public sealed class ValkeyContainerFixture : RouterCollectionFixture, IAsyncDisposable
|
||||
{
|
||||
private RedisContainer? _container;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Valkey container hostname.
|
||||
/// </summary>
|
||||
public string HostName => _container?.Hostname ?? "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Valkey container mapped port.
|
||||
/// </summary>
|
||||
public int Port => _container?.GetMappedPublicPort(6379) ?? 6379;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the Valkey container.
|
||||
/// </summary>
|
||||
public string ConnectionString => $"{HostName}:{Port}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a null logger for tests.
|
||||
/// </summary>
|
||||
public ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the container is running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _container is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates Valkey transport options configured for the test container.
|
||||
/// </summary>
|
||||
public ValkeyTransportOptions CreateOptions(int? database = null)
|
||||
{
|
||||
return new ValkeyTransportOptions
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
Database = database,
|
||||
InitializationTimeout = TimeSpan.FromSeconds(30),
|
||||
ConnectRetry = 3,
|
||||
AbortOnConnectFail = false,
|
||||
IdempotencyKeyPrefix = "test:idem:"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ValkeyConnectionFactory configured for the test container.
|
||||
/// </summary>
|
||||
public ValkeyConnectionFactory CreateConnectionFactory(int? database = null)
|
||||
{
|
||||
var options = CreateOptions(database);
|
||||
return new ValkeyConnectionFactory(
|
||||
Options.Create(options),
|
||||
GetLogger<ValkeyConnectionFactory>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates message queue options for testing.
|
||||
/// </summary>
|
||||
public StellaOps.Messaging.MessageQueueOptions CreateQueueOptions(
|
||||
string? queueName = null,
|
||||
string? consumerGroup = null,
|
||||
string? consumerName = null)
|
||||
{
|
||||
return new StellaOps.Messaging.MessageQueueOptions
|
||||
{
|
||||
QueueName = queueName ?? $"test:queue:{Guid.NewGuid():N}",
|
||||
ConsumerGroup = consumerGroup ?? "test-group",
|
||||
ConsumerName = consumerName ?? $"consumer-{Environment.ProcessId}",
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(30),
|
||||
MaxDeliveryAttempts = 3,
|
||||
IdempotencyWindow = TimeSpan.FromMinutes(5),
|
||||
ApproximateMaxLength = 10000,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(100),
|
||||
RetryMaxBackoff = TimeSpan.FromSeconds(10),
|
||||
RetryBackoffMultiplier = 2.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ValkeyMessageQueue for testing.
|
||||
/// </summary>
|
||||
public ValkeyMessageQueue<TMessage> CreateMessageQueue<TMessage>(
|
||||
ValkeyConnectionFactory? connectionFactory = null,
|
||||
StellaOps.Messaging.MessageQueueOptions? queueOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
where TMessage : class
|
||||
{
|
||||
connectionFactory ??= CreateConnectionFactory();
|
||||
queueOptions ??= CreateQueueOptions();
|
||||
var transportOptions = CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
connectionFactory,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
GetLogger<ValkeyMessageQueue<TMessage>>(),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the container.
|
||||
/// </summary>
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
if (_container is null)
|
||||
{
|
||||
throw new InvalidOperationException("Valkey container is not running.");
|
||||
}
|
||||
|
||||
await _container.StopAsync();
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new RedisBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures during skip.
|
||||
}
|
||||
|
||||
_container = null;
|
||||
|
||||
throw SkipException.ForSkip(
|
||||
$"Valkey integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task DisposeAsyncCore()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Valkey integration tests.
|
||||
/// All tests in this collection share a single Valkey container.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ValkeyIntegrationTestCollection : ICollectionFixture<ValkeyContainerFixture>
|
||||
{
|
||||
public const string Name = "Valkey Integration Tests";
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyIntegrationFactAttribute.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Attribute that skips Valkey integration tests when Docker is not available
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Fact attribute for Valkey integration tests.
|
||||
/// Skips tests when STELLAOPS_TEST_VALKEY environment variable is not set.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class ValkeyIntegrationFactAttribute : FactAttribute
|
||||
{
|
||||
public ValkeyIntegrationFactAttribute()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("STELLAOPS_TEST_VALKEY");
|
||||
if (!string.Equals(enabled, "1", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Skip = "Valkey integration tests are opt-in. Set STELLAOPS_TEST_VALKEY=1 (requires Docker/Testcontainers).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Theory attribute for Valkey integration tests.
|
||||
/// Skips tests when STELLAOPS_TEST_VALKEY environment variable is not set.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class ValkeyIntegrationTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public ValkeyIntegrationTheoryAttribute()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("STELLAOPS_TEST_VALKEY");
|
||||
if (!string.Equals(enabled, "1", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Skip = "Valkey integration tests are opt-in. Set STELLAOPS_TEST_VALKEY=1 (requires Docker/Testcontainers).";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.Valkey.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure since not needed for Messaging tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Testcontainers.Redis" Version="3.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,723 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyTransportComplianceTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0003 - Router + Messaging Test Implementation
|
||||
// Task: MESSAGING-5100-004 - Valkey transport compliance tests
|
||||
// Description: Transport compliance tests for Valkey transport covering roundtrip,
|
||||
// pub/sub semantics, consumer groups, ack/nack, and backpressure.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey.Tests.Fixtures;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transport compliance tests for Valkey transport.
|
||||
/// Validates:
|
||||
/// - Message roundtrip (enqueue → lease → message preserved)
|
||||
/// - Consumer group semantics (exclusive delivery, multiple consumers)
|
||||
/// - Ack/Nack behavior (acknowledge, release, dead-letter)
|
||||
/// - Idempotency (duplicate detection)
|
||||
/// - Backpressure (batch limits, pending counts)
|
||||
/// - Lease management (renewal, expiration, claiming)
|
||||
/// </summary>
|
||||
[Collection(ValkeyIntegrationTestCollection.Name)]
|
||||
public sealed class ValkeyTransportComplianceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ValkeyContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private ValkeyConnectionFactory? _connectionFactory;
|
||||
|
||||
public ValkeyTransportComplianceTests(ValkeyContainerFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_connectionFactory = _fixture.CreateConnectionFactory();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_connectionFactory is not null)
|
||||
{
|
||||
await _connectionFactory.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Message Roundtrip Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_SimpleMessage_AllFieldsPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var original = new TestMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = "Hello Valkey!",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Tags = new[] { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var enqueueResult = await queue.EnqueueAsync(original);
|
||||
enqueueResult.Success.Should().BeTrue();
|
||||
enqueueResult.MessageId.Should().NotBeNullOrEmpty();
|
||||
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases.Should().HaveCount(1);
|
||||
var lease = leases[0];
|
||||
lease.Message.Id.Should().Be(original.Id);
|
||||
lease.Message.Content.Should().Be(original.Content);
|
||||
lease.Message.Tags.Should().BeEquivalentTo(original.Tags);
|
||||
lease.Attempt.Should().Be(1);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
_output.WriteLine("Roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_ComplexMessage_PreservedAfterSerialization()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<ComplexMessage>();
|
||||
var original = new ComplexMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Metadata = new Dictionary<string, object>
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42,
|
||||
["key3"] = true
|
||||
},
|
||||
NestedData = new NestedObject
|
||||
{
|
||||
Name = "nested",
|
||||
Value = 123.45m
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(original);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
var lease = leases[0];
|
||||
lease.Message.Id.Should().Be(original.Id);
|
||||
lease.Message.NestedData.Name.Should().Be(original.NestedData.Name);
|
||||
lease.Message.NestedData.Value.Should().Be(original.NestedData.Value);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
_output.WriteLine("Complex message roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Roundtrip_BinaryData_PreservesAllBytes()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<BinaryMessage>();
|
||||
var binaryPayload = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();
|
||||
var original = new BinaryMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Data = binaryPayload
|
||||
};
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(original);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].Message.Data.Should().BeEquivalentTo(binaryPayload);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Binary data roundtrip test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(10)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Roundtrip_MultipleMessages_OrderPreserved(int messageCount)
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var messages = Enumerable.Range(1, messageCount)
|
||||
.Select(i => new TestMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = $"Message-{i:D5}",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddMilliseconds(i)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Act - Enqueue all
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
await queue.EnqueueAsync(msg);
|
||||
}
|
||||
|
||||
// Lease and verify order
|
||||
var receivedContents = new List<string>();
|
||||
int remaining = messageCount;
|
||||
while (remaining > 0)
|
||||
{
|
||||
var batchSize = Math.Min(remaining, 50);
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = batchSize });
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
receivedContents.Add(lease.Message.Content!);
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
remaining -= leases.Count;
|
||||
}
|
||||
|
||||
// Assert - FIFO order preserved
|
||||
var expectedContents = messages.Select(m => m.Content).ToList();
|
||||
receivedContents.Should().BeEquivalentTo(expectedContents, options => options.WithStrictOrdering());
|
||||
_output.WriteLine($"Order preserved for {messageCount} messages");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumer Group Semantics Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerGroup_MultipleConsumers_ExclusiveDelivery()
|
||||
{
|
||||
// Arrange - Two consumers in same group
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
var queue1 = CreateQueue<TestMessage>(queueOptions: queueOptions, consumerName: "consumer-1");
|
||||
var queue2 = CreateQueue<TestMessage>(queueOptions: queueOptions, consumerName: "consumer-2");
|
||||
|
||||
var messages = Enumerable.Range(1, 20)
|
||||
.Select(i => new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" })
|
||||
.ToList();
|
||||
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
await queue1.EnqueueAsync(msg);
|
||||
}
|
||||
|
||||
// Act - Both consumers lease
|
||||
var leases1 = await queue1.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
var leases2 = await queue2.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert - Messages should be distributed (no duplicates)
|
||||
var allIds = leases1.Concat(leases2).Select(l => l.Message.Id).ToList();
|
||||
allIds.Should().OnlyHaveUniqueItems("each message should be delivered to only one consumer");
|
||||
allIds.Should().HaveCount(20, "all messages should be delivered");
|
||||
|
||||
// Cleanup
|
||||
foreach (var lease in leases1.Concat(leases2))
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Exclusive delivery test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConsumerGroup_DifferentGroups_EachReceivesAllMessages()
|
||||
{
|
||||
// Arrange - Two different consumer groups
|
||||
var queueName = $"test:queue:{Guid.NewGuid():N}";
|
||||
var options1 = _fixture.CreateQueueOptions(queueName: queueName, consumerGroup: "group-1");
|
||||
var options2 = _fixture.CreateQueueOptions(queueName: queueName, consumerGroup: "group-2");
|
||||
|
||||
var queue1 = CreateQueue<TestMessage>(queueOptions: options1);
|
||||
var queue2 = CreateQueue<TestMessage>(queueOptions: options2);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Shared message" };
|
||||
|
||||
// Act - Enqueue to one queue (same stream)
|
||||
await queue1.EnqueueAsync(message);
|
||||
|
||||
// Both groups should receive the message
|
||||
var leases1 = await queue1.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
var leases2 = await queue2.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases1.Should().HaveCount(1);
|
||||
leases2.Should().HaveCount(1);
|
||||
leases1[0].Message.Id.Should().Be(message.Id);
|
||||
leases2[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
await leases1[0].AcknowledgeAsync();
|
||||
await leases2[0].AcknowledgeAsync();
|
||||
|
||||
_output.WriteLine("Different groups test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ack/Nack/Release Semantics Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Acknowledge_RemovesMessageFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Ack test" });
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
await leases[0].AcknowledgeAsync();
|
||||
|
||||
// Assert - No more messages
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(0);
|
||||
|
||||
var moreLeases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
moreLeases.Should().BeEmpty();
|
||||
|
||||
_output.WriteLine("Acknowledge removes message test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Release_Retry_MessageBecomesAvailableAgain()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.RetryInitialBackoff = TimeSpan.Zero; // No backoff for test speed
|
||||
var queue = CreateQueue<TestMessage>(queueOptions: queueOptions);
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Retry test" };
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
// Act - Lease and release for retry
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
leases.Should().HaveCount(1);
|
||||
leases[0].Attempt.Should().Be(1);
|
||||
await leases[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
|
||||
// Wait briefly for re-enqueue
|
||||
await Task.Delay(100);
|
||||
|
||||
// Lease again
|
||||
var retryLeases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
retryLeases.Should().HaveCount(1);
|
||||
retryLeases[0].Message.Id.Should().Be(message.Id);
|
||||
retryLeases[0].Attempt.Should().Be(2);
|
||||
|
||||
await retryLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Release retry test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task DeadLetter_MovesMessageToDeadLetterQueue()
|
||||
{
|
||||
// Arrange
|
||||
var mainQueueName = $"test:main:{Guid.NewGuid():N}";
|
||||
var dlqName = $"test:dlq:{Guid.NewGuid():N}";
|
||||
|
||||
var mainOptions = _fixture.CreateQueueOptions(queueName: mainQueueName);
|
||||
mainOptions.DeadLetterQueue = dlqName;
|
||||
|
||||
var dlqOptions = _fixture.CreateQueueOptions(queueName: dlqName);
|
||||
|
||||
var mainQueue = CreateQueue<TestMessage>(queueOptions: mainOptions);
|
||||
var dlqQueue = CreateQueue<TestMessage>(queueOptions: dlqOptions);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "DLQ test" };
|
||||
await mainQueue.EnqueueAsync(message);
|
||||
|
||||
// Act
|
||||
var leases = await mainQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
await leases[0].DeadLetterAsync("test-reason");
|
||||
|
||||
// Assert - Message should be in DLQ
|
||||
var dlqLeases = await dlqQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
dlqLeases.Should().HaveCount(1);
|
||||
dlqLeases[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
// Main queue should be empty
|
||||
var mainPending = await mainQueue.GetPendingCountAsync();
|
||||
mainPending.Should().Be(0);
|
||||
|
||||
await dlqLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Dead letter test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task MaxDeliveryAttempts_ExceededCausesDeadLetter()
|
||||
{
|
||||
// Arrange
|
||||
var mainQueueName = $"test:main:{Guid.NewGuid():N}";
|
||||
var dlqName = $"test:dlq:{Guid.NewGuid():N}";
|
||||
|
||||
var mainOptions = _fixture.CreateQueueOptions(queueName: mainQueueName);
|
||||
mainOptions.MaxDeliveryAttempts = 3;
|
||||
mainOptions.DeadLetterQueue = dlqName;
|
||||
mainOptions.RetryInitialBackoff = TimeSpan.Zero;
|
||||
|
||||
var dlqOptions = _fixture.CreateQueueOptions(queueName: dlqName);
|
||||
|
||||
var mainQueue = CreateQueue<TestMessage>(queueOptions: mainOptions);
|
||||
var dlqQueue = CreateQueue<TestMessage>(queueOptions: dlqOptions);
|
||||
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Max attempts test" };
|
||||
await mainQueue.EnqueueAsync(message);
|
||||
|
||||
// Act - Retry until max attempts exceeded
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var leases = await mainQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
if (leases.Count == 0) break;
|
||||
await leases[0].ReleaseAsync(ReleaseDisposition.Retry);
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
// Wait for final retry to dead-letter
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert - Message should be in DLQ
|
||||
var dlqLeases = await dlqQueue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
dlqLeases.Should().HaveCount(1);
|
||||
dlqLeases[0].Message.Id.Should().Be(message.Id);
|
||||
|
||||
await dlqLeases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Max delivery attempts test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Idempotency Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Idempotency_DuplicateKey_ReturnsExistingMessage()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var idempotencyKey = Guid.NewGuid().ToString();
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Idempotent message" };
|
||||
|
||||
// Act - Enqueue twice with same key
|
||||
var result1 = await queue.EnqueueAsync(message, EnqueueOptions.WithIdempotencyKey(idempotencyKey));
|
||||
var result2 = await queue.EnqueueAsync(message, EnqueueOptions.WithIdempotencyKey(idempotencyKey));
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue();
|
||||
result1.WasDuplicate.Should().BeFalse();
|
||||
|
||||
result2.Success.Should().BeTrue();
|
||||
result2.WasDuplicate.Should().BeTrue();
|
||||
result2.MessageId.Should().Be(result1.MessageId);
|
||||
|
||||
// Only one message should be in queue
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
leases.Should().HaveCount(1);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Idempotency test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Idempotency_DifferentKeys_BothMessagesEnqueued()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var message1 = new TestMessage { Id = Guid.NewGuid(), Content = "Message 1" };
|
||||
var message2 = new TestMessage { Id = Guid.NewGuid(), Content = "Message 2" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message1, EnqueueOptions.WithIdempotencyKey("key-1"));
|
||||
await queue.EnqueueAsync(message2, EnqueueOptions.WithIdempotencyKey("key-2"));
|
||||
|
||||
// Assert
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
leases.Should().HaveCount(2);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Different idempotency keys test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Backpressure Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_BatchSize_LimitsMessageCount()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" });
|
||||
}
|
||||
|
||||
// Act - Request only 10
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert
|
||||
leases.Should().HaveCount(10);
|
||||
|
||||
// Cleanup
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
// Remaining messages
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(0); // Not pending because not leased yet
|
||||
|
||||
_output.WriteLine("Batch size backpressure test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_PendingCount_ReflectsUnacknowledged()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = $"Msg-{i}" });
|
||||
}
|
||||
|
||||
// Act - Lease 30, ack 10
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 30 });
|
||||
leases.Should().HaveCount(30);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await leases[i].AcknowledgeAsync();
|
||||
}
|
||||
|
||||
// Assert - 20 still pending
|
||||
var pending = await queue.GetPendingCountAsync();
|
||||
pending.Should().Be(20);
|
||||
|
||||
// Cleanup
|
||||
for (int i = 10; i < 30; i++)
|
||||
{
|
||||
await leases[i].AcknowledgeAsync();
|
||||
}
|
||||
|
||||
_output.WriteLine("Pending count test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Backpressure_EmptyQueue_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 10 });
|
||||
|
||||
// Assert
|
||||
leases.Should().BeEmpty();
|
||||
_output.WriteLine("Empty queue test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lease Management Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task LeaseRenewal_ExtendsLeaseTime()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Renewal test" });
|
||||
|
||||
// Act
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest
|
||||
{
|
||||
BatchSize = 1,
|
||||
LeaseDuration = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
var originalExpiry = leases[0].LeaseExpiresAt;
|
||||
|
||||
await leases[0].RenewAsync(TimeSpan.FromMinutes(5));
|
||||
|
||||
// Assert - Lease should be extended
|
||||
leases[0].LeaseExpiresAt.Should().BeAfter(originalExpiry);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Lease renewal test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ClaimExpired_RecoversStaleMessages()
|
||||
{
|
||||
// Arrange
|
||||
var queueOptions = _fixture.CreateQueueOptions();
|
||||
queueOptions.DefaultLeaseDuration = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
var queue = CreateQueue<TestMessage>(queueOptions: queueOptions);
|
||||
await queue.EnqueueAsync(new TestMessage { Id = Guid.NewGuid(), Content = "Stale test" });
|
||||
|
||||
// Lease and let expire
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
leases.Should().HaveCount(1);
|
||||
|
||||
// Wait for lease to expire
|
||||
await Task.Delay(500);
|
||||
|
||||
// Act - Claim expired
|
||||
var claimed = await queue.ClaimExpiredAsync(new ClaimRequest
|
||||
{
|
||||
BatchSize = 10,
|
||||
MinIdleTime = TimeSpan.FromMilliseconds(100),
|
||||
MinDeliveryAttempts = 1
|
||||
});
|
||||
|
||||
// Assert
|
||||
claimed.Should().HaveCount(1);
|
||||
claimed[0].Message.Content.Should().Be("Stale test");
|
||||
claimed[0].Attempt.Should().BeGreaterThan(1);
|
||||
|
||||
await claimed[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Claim expired test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata/Headers Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Metadata_CorrelationId_PreservedInLease()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Correlation test" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message, EnqueueOptions.WithCorrelation(correlationId));
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].CorrelationId.Should().Be(correlationId);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Correlation ID test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task Metadata_TenantId_PreservedInLease()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
var tenantId = "tenant-123";
|
||||
var message = new TestMessage { Id = Guid.NewGuid(), Content = "Tenant test" };
|
||||
|
||||
// Act
|
||||
await queue.EnqueueAsync(message, new EnqueueOptions { TenantId = tenantId });
|
||||
var leases = await queue.LeaseAsync(new LeaseRequest { BatchSize = 1 });
|
||||
|
||||
// Assert
|
||||
leases[0].TenantId.Should().Be(tenantId);
|
||||
|
||||
await leases[0].AcknowledgeAsync();
|
||||
_output.WriteLine("Tenant ID test passed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Resilience Tests
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConnectionResilience_Ping_Succeeds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = async () => await _connectionFactory!.PingAsync();
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync();
|
||||
_output.WriteLine("Ping test passed");
|
||||
}
|
||||
|
||||
[ValkeyIntegrationFact]
|
||||
public async Task ConnectionResilience_QueueProviderName_IsValkey()
|
||||
{
|
||||
// Arrange
|
||||
var queue = CreateQueue<TestMessage>();
|
||||
|
||||
// Assert
|
||||
queue.ProviderName.Should().Be("valkey");
|
||||
queue.QueueName.Should().NotBeNullOrEmpty();
|
||||
|
||||
_output.WriteLine("Provider name test passed");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ValkeyMessageQueue<TMessage> CreateQueue<TMessage>(
|
||||
MessageQueueOptions? queueOptions = null,
|
||||
string? consumerName = null)
|
||||
where TMessage : class
|
||||
{
|
||||
queueOptions ??= _fixture.CreateQueueOptions();
|
||||
if (consumerName is not null)
|
||||
{
|
||||
queueOptions.ConsumerName = consumerName;
|
||||
}
|
||||
|
||||
var transportOptions = _fixture.CreateOptions();
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory!,
|
||||
queueOptions,
|
||||
transportOptions,
|
||||
_fixture.GetLogger<ValkeyMessageQueue<TMessage>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Message Types
|
||||
|
||||
public sealed class TestMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ComplexMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
public NestedObject? NestedData { get; set; }
|
||||
}
|
||||
|
||||
public sealed class NestedObject
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BinaryMessage
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public byte[]? Data { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -9,17 +9,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Metrics\StellaOps.Metrics.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -273,7 +273,6 @@ public sealed class MinimalApiBindingIntegrationTests : IAsyncLifetime
|
||||
app.MapPatch("/items/{itemId}", async ([FromRoute] string itemId, HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
using StellaOps.TestKit;
|
||||
var bodyText = await reader.ReadToEndAsync();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var request = JsonSerializer.Deserialize<PatchItemRequestDto>(bodyText, options);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Test packages inherited from Directory.Build.props -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,13 +21,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -116,7 +116,6 @@ public sealed class StellaRouterBridgeIntegrationTests : IAsyncLifetime
|
||||
app.MapPut("/api/items/{id}", async (string id, HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
using StellaOps.TestKit;
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
var name = data.GetProperty("name").GetString();
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
|
||||
namespace StellaOps.Microservice.SourceGen.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StellaEndpointGenerator"/>.
|
||||
/// </summary>
|
||||
public sealed class StellaEndpointGeneratorTests
|
||||
{
|
||||
#region Helper Methods
|
||||
|
||||
private static GeneratorDriverRunResult RunGenerator(string source)
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(source);
|
||||
|
||||
var references = new List<MetadataReference>
|
||||
{
|
||||
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
|
||||
MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location),
|
||||
MetadataReference.CreateFromFile(typeof(Task).Assembly.Location),
|
||||
MetadataReference.CreateFromFile(typeof(StellaEndpointAttribute).Assembly.Location),
|
||||
MetadataReference.CreateFromFile(typeof(Router.Common.Models.EndpointDescriptor).Assembly.Location),
|
||||
};
|
||||
|
||||
// Add System.Runtime reference
|
||||
var runtimePath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
|
||||
references.Add(MetadataReference.CreateFromFile(Path.Combine(runtimePath, "System.Runtime.dll")));
|
||||
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName: "TestAssembly",
|
||||
syntaxTrees: [syntaxTree],
|
||||
references: references,
|
||||
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var generator = new StellaEndpointGenerator();
|
||||
|
||||
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
|
||||
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _);
|
||||
|
||||
return driver.GetRunResult();
|
||||
}
|
||||
|
||||
private static ImmutableArray<Diagnostic> GetDiagnostics(GeneratorDriverRunResult result)
|
||||
{
|
||||
return result.Results.SelectMany(r => r.Diagnostics).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string? GetGeneratedSource(GeneratorDriverRunResult result, string hintName)
|
||||
{
|
||||
var generatedSources = result.Results
|
||||
.SelectMany(r => r.GeneratedSources)
|
||||
.Where(s => s.HintName == hintName)
|
||||
.ToList();
|
||||
|
||||
return generatedSources.FirstOrDefault().SourceText?.ToString();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Generation Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithTypedEndpoint_GeneratesSource()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record GetUserRequest(string UserId);
|
||||
public record GetUserResponse(string Name, string Email);
|
||||
|
||||
[StellaEndpoint("GET", "/users/{userId}")]
|
||||
public class GetUserEndpoint : IStellaEndpoint<GetUserRequest, GetUserResponse>
|
||||
{
|
||||
public Task<GetUserResponse> HandleAsync(GetUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new GetUserResponse("Test", "test@example.com"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
result.GeneratedTrees.Should().NotBeEmpty();
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().NotBeNullOrEmpty();
|
||||
generatedSource.Should().Contain("GetUserEndpoint");
|
||||
generatedSource.Should().Contain("/users/{userId}");
|
||||
generatedSource.Should().Contain("GET");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithRawEndpoint_GeneratesSource()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
[StellaEndpoint("POST", "/raw/upload")]
|
||||
public class UploadEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(RawResponse.Ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
result.GeneratedTrees.Should().NotBeEmpty();
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().NotBeNullOrEmpty();
|
||||
generatedSource.Should().Contain("UploadEndpoint");
|
||||
generatedSource.Should().Contain("/raw/upload");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithMultipleEndpoints_GeneratesAll()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Request1();
|
||||
public record Response1();
|
||||
public record Request2();
|
||||
public record Response2();
|
||||
|
||||
[StellaEndpoint("GET", "/endpoint1")]
|
||||
public class Endpoint1 : IStellaEndpoint<Request1, Response1>
|
||||
{
|
||||
public Task<Response1> HandleAsync(Request1 request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Response1());
|
||||
}
|
||||
|
||||
[StellaEndpoint("POST", "/endpoint2")]
|
||||
public class Endpoint2 : IStellaEndpoint<Request2, Response2>
|
||||
{
|
||||
public Task<Response2> HandleAsync(Request2 request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Response2());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().NotBeNullOrEmpty();
|
||||
generatedSource.Should().Contain("Endpoint1");
|
||||
generatedSource.Should().Contain("Endpoint2");
|
||||
generatedSource.Should().Contain("/endpoint1");
|
||||
generatedSource.Should().Contain("/endpoint2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithNoEndpoints_GeneratesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class RegularClass
|
||||
{
|
||||
public void DoSomething() { }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attribute Property Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithTimeout_IncludesTimeoutInGeneration()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/slow", TimeoutSeconds = 120)]
|
||||
public class SlowEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().Contain("FromSeconds(120)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithStreaming_IncludesStreamingFlag()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
[StellaEndpoint("POST", "/stream", SupportsStreaming = true)]
|
||||
public class StreamEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(RawResponse.Ok());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().Contain("SupportsStreaming = true");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithRequiredClaims_IncludesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("DELETE", "/admin/users", RequiredClaims = new[] { "admin", "user:delete" })]
|
||||
public class AdminEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().Contain("admin");
|
||||
generatedSource.Should().Contain("user:delete");
|
||||
generatedSource.Should().Contain("ClaimRequirement");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("DELETE")]
|
||||
[InlineData("PATCH")]
|
||||
public void Generator_WithHttpMethod_NormalizesToUppercase(string method)
|
||||
{
|
||||
// Arrange
|
||||
var source = $$"""
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("{{method.ToLowerInvariant()}}", "/test")]
|
||||
public class TestEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().Contain($"Method = \"{method}\"");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Cases Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithAbstractClass_ReportsDiagnostic()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/abstract")]
|
||||
public abstract class AbstractEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public abstract Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert - Abstract classes are filtered at syntax level, so no diagnostic
|
||||
// but also no generated code for this class
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithMissingInterface_ReportsDiagnostic()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
[StellaEndpoint("GET", "/no-interface")]
|
||||
public class NoInterfaceEndpoint
|
||||
{
|
||||
public void Handle() { }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var diagnostics = GetDiagnostics(result);
|
||||
diagnostics.Should().Contain(d => d.Id == "STELLA001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generated Provider Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_GeneratesProviderClass()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/test")]
|
||||
public class TestEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var providerSource = GetGeneratedSource(result, "GeneratedEndpointProvider.g.cs");
|
||||
providerSource.Should().NotBeNullOrEmpty();
|
||||
providerSource.Should().Contain("GeneratedEndpointProvider");
|
||||
providerSource.Should().Contain("IGeneratedEndpointProvider");
|
||||
providerSource.Should().Contain("GetEndpoints()");
|
||||
providerSource.Should().Contain("RegisterHandlers");
|
||||
providerSource.Should().Contain("GetHandlerTypes()");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Namespace Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithGlobalNamespace_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/global")]
|
||||
public class GlobalEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().NotBeNullOrEmpty();
|
||||
generatedSource.Should().Contain("GlobalEndpoint");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithNestedNamespace_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
namespace Company.Product.Module
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/nested")]
|
||||
public class NestedEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().NotBeNullOrEmpty();
|
||||
generatedSource.Should().Contain("Company.Product.Module.NestedEndpoint");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Escaping Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generator_WithSpecialCharactersInPath_EscapesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Microservice;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace TestNamespace
|
||||
{
|
||||
public record Req();
|
||||
public record Resp();
|
||||
|
||||
[StellaEndpoint("GET", "/users/{userId}/profile")]
|
||||
public class ProfileEndpoint : IStellaEndpoint<Req, Resp>
|
||||
{
|
||||
public Task<Resp> HandleAsync(Req request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Resp());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = RunGenerator(source);
|
||||
|
||||
// Assert
|
||||
var generatedSource = GetGeneratedSource(result, "StellaEndpoints.g.cs");
|
||||
generatedSource.Should().Contain("/users/{userId}/profile");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Microservice.SourceGen.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,206 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointDiscoveryService"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointDiscoveryServiceTests
|
||||
{
|
||||
private readonly Mock<IEndpointDiscoveryProvider> _discoveryProviderMock;
|
||||
private readonly Mock<IMicroserviceYamlLoader> _yamlLoaderMock;
|
||||
private readonly Mock<IEndpointOverrideMerger> _mergerMock;
|
||||
|
||||
public EndpointDiscoveryServiceTests()
|
||||
{
|
||||
_discoveryProviderMock = new Mock<IEndpointDiscoveryProvider>();
|
||||
_yamlLoaderMock = new Mock<IMicroserviceYamlLoader>();
|
||||
_mergerMock = new Mock<IEndpointOverrideMerger>();
|
||||
|
||||
// Default setups
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints())
|
||||
.Returns(new List<EndpointDescriptor>());
|
||||
_yamlLoaderMock.Setup(l => l.Load())
|
||||
.Returns((MicroserviceYamlConfig?)null);
|
||||
_mergerMock.Setup(m => m.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), It.IsAny<MicroserviceYamlConfig?>()))
|
||||
.Returns<IReadOnlyList<EndpointDescriptor>, MicroserviceYamlConfig?>((e, _) => e);
|
||||
}
|
||||
|
||||
private EndpointDiscoveryService CreateService()
|
||||
{
|
||||
return new EndpointDiscoveryService(
|
||||
_discoveryProviderMock.Object,
|
||||
_yamlLoaderMock.Object,
|
||||
_mergerMock.Object,
|
||||
NullLogger<EndpointDiscoveryService>.Instance);
|
||||
}
|
||||
|
||||
#region DiscoverEndpoints Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_CallsDiscoveryProvider()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
_discoveryProviderMock.Verify(d => d.DiscoverEndpoints(), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_LoadsYamlConfig()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
_yamlLoaderMock.Verify(l => l.Load(), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_MergesCodeAndYaml()
|
||||
{
|
||||
// Arrange
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users" }
|
||||
};
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(codeEndpoints);
|
||||
|
||||
var yamlConfig = new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointOverrideConfig { Method = "GET", Path = "/api/users", DefaultTimeout = "30s" }
|
||||
]
|
||||
};
|
||||
_yamlLoaderMock.Setup(l => l.Load()).Returns(yamlConfig);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
_mergerMock.Verify(m => m.Merge(codeEndpoints, yamlConfig), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_ReturnsMergedEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users" }
|
||||
};
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(codeEndpoints);
|
||||
|
||||
var mergedEndpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users", DefaultTimeout = TimeSpan.FromSeconds(30) }
|
||||
};
|
||||
_mergerMock.Setup(m => m.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), It.IsAny<MicroserviceYamlConfig?>()))
|
||||
.Returns(mergedEndpoints);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(mergedEndpoints);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_WhenYamlLoadFails_UsesCodeEndpointsOnly()
|
||||
{
|
||||
// Arrange
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users" }
|
||||
};
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(codeEndpoints);
|
||||
_yamlLoaderMock.Setup(l => l.Load()).Throws(new FileNotFoundException("YAML not found"));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
service.DiscoverEndpoints();
|
||||
|
||||
// Assert - merger should be called with null config
|
||||
_mergerMock.Verify(m => m.Merge(codeEndpoints, null), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_WithMultipleEndpoints_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users" },
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "POST", Path = "/api/users" },
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users/{id}" },
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "DELETE", Path = "/api/users/{id}" }
|
||||
};
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(endpoints);
|
||||
_mergerMock.Setup(m => m.Merge(endpoints, null)).Returns(endpoints);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_EmptyEndpoints_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(new List<EndpointDescriptor>());
|
||||
_mergerMock.Setup(m => m.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), null))
|
||||
.Returns(new List<EndpointDescriptor>());
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result1 = service.DiscoverEndpoints();
|
||||
var result2 = service.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
_discoveryProviderMock.Verify(d => d.DiscoverEndpoints(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointRegistry"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointRegistryTests
|
||||
{
|
||||
private static EndpointDescriptor CreateEndpoint(string method, string path)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
#region Register Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Register_SingleEndpoint_AddsToRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint = CreateEndpoint("GET", "/api/users");
|
||||
|
||||
// Act
|
||||
registry.Register(endpoint);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(1);
|
||||
registry.GetAllEndpoints()[0].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Register_MultipleEndpoints_AddsAllToRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
registry.Register(CreateEndpoint("POST", "/api/users"));
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegisterAll_AddsAllEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoints = new[]
|
||||
{
|
||||
CreateEndpoint("GET", "/api/users"),
|
||||
CreateEndpoint("POST", "/api/users"),
|
||||
CreateEndpoint("DELETE", "/api/users/{id}")
|
||||
};
|
||||
|
||||
// Act
|
||||
registry.RegisterAll(endpoints);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegisterAll_WithEmptyCollection_DoesNotAddAny()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
registry.RegisterAll([]);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ExactMethodAndPath_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.Endpoint.Path.Should().Be("/api/users");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingMethod_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("POST", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/items", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MethodIsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("get", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("Get", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathIsCaseInsensitive_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: true);
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/API/USERS", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/Api/Users", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathIsCaseSensitive_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: false);
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/API/USERS", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Path Parameter Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathWithParameter_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/123", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.PathParameters.Should().ContainKey("id");
|
||||
match.PathParameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathWithMultipleParameters_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{userId}/orders/{orderId}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/456/orders/789", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.PathParameters.Should().HaveCount(2);
|
||||
match.PathParameters["userId"].Should().Be("456");
|
||||
match.PathParameters["orderId"].Should().Be("789");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathParameterWithSpecialChars_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/items/{itemId}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/items/item-with-dashes", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match!.PathParameters["itemId"].Should().Be("item-with-dashes");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_EmptyPathParameter_DoesNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Multiple Endpoints Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_FirstMatchingEndpoint_ReturnsFirst()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
registry.Register(CreateEndpoint("GET", "/api/users")); // duplicate
|
||||
|
||||
// Act
|
||||
registry.TryMatch("GET", "/api/users", out var match);
|
||||
|
||||
// Assert - should return the first registered
|
||||
match.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SelectsCorrectEndpointByMethod()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10)
|
||||
});
|
||||
registry.Register(new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
registry.TryMatch("POST", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
match.Should().NotBeNull();
|
||||
match!.Endpoint.Method.Should().Be("POST");
|
||||
match.Endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllEndpoints Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetAllEndpoints_EmptyRegistry_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetAllEndpoints_ReturnsAllRegisteredEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint1 = CreateEndpoint("GET", "/api/a");
|
||||
var endpoint2 = CreateEndpoint("POST", "/api/b");
|
||||
var endpoint3 = CreateEndpoint("DELETE", "/api/c");
|
||||
registry.RegisterAll([endpoint1, endpoint2, endpoint3]);
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().HaveCount(3);
|
||||
endpoints.Should().Contain(endpoint1);
|
||||
endpoints.Should().Contain(endpoint2);
|
||||
endpoints.Should().Contain(endpoint3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetAllEndpoints_PreservesRegistrationOrder()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint1 = CreateEndpoint("GET", "/first");
|
||||
var endpoint2 = CreateEndpoint("GET", "/second");
|
||||
var endpoint3 = CreateEndpoint("GET", "/third");
|
||||
registry.Register(endpoint1);
|
||||
registry.Register(endpoint2);
|
||||
registry.Register(endpoint3);
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints[0].Should().Be(endpoint1);
|
||||
endpoints[1].Should().Be(endpoint2);
|
||||
endpoints[2].Should().Be(endpoint3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_DefaultCaseInsensitive_IsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/Test"));
|
||||
|
||||
// Act & Assert - should match case-insensitively by default
|
||||
registry.TryMatch("GET", "/api/test", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_ExplicitCaseInsensitiveFalse_IsCaseSensitive()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: false);
|
||||
registry.Register(CreateEndpoint("GET", "/api/Test"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/api/Test", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/api/test", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HeaderCollection"/>.
|
||||
/// </summary>
|
||||
public sealed class HeaderCollectionTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Default_CreatesEmptyCollection()
|
||||
{
|
||||
// Arrange & Act
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_WithKeyValuePairs_AddsAllHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var pairs = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Content-Type", "application/json"),
|
||||
new KeyValuePair<string, string>("Accept", "application/json")
|
||||
};
|
||||
|
||||
// Act
|
||||
var headers = new HeaderCollection(pairs);
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
headers["Accept"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_WithDuplicateKeys_AddsMultipleValues()
|
||||
{
|
||||
// Arrange
|
||||
var pairs = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Accept", "application/json"),
|
||||
new KeyValuePair<string, string>("Accept", "text/plain")
|
||||
};
|
||||
|
||||
// Act
|
||||
var headers = new HeaderCollection(pairs);
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Accept").Should().BeEquivalentTo(["application/json", "text/plain"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_IsSharedInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var empty1 = HeaderCollection.Empty;
|
||||
var empty2 = HeaderCollection.Empty;
|
||||
|
||||
// Assert
|
||||
empty1.Should().BeSameAs(empty2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_HasNoHeaders()
|
||||
{
|
||||
// Arrange & Act
|
||||
var empty = HeaderCollection.Empty;
|
||||
|
||||
// Assert
|
||||
empty.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Indexer Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Indexer_ExistingKey_ReturnsFirstValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var value = headers["Content-Type"];
|
||||
|
||||
// Assert
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Indexer_MultipleValues_ReturnsFirstValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
|
||||
// Act
|
||||
var value = headers["Accept"];
|
||||
|
||||
// Assert
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Indexer_NonexistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var value = headers["X-Missing"];
|
||||
|
||||
// Assert
|
||||
value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Indexer_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers["content-type"].Should().Be("application/json");
|
||||
headers["CONTENT-TYPE"].Should().Be("application/json");
|
||||
headers["Content-TYPE"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Add Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Add_NewKey_AddsHeader()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Add_ExistingKey_AppendsValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
|
||||
// Act
|
||||
headers.Add("Accept", "text/plain");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Accept").Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Add_CaseInsensitiveKey_AppendsToExisting()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
headers.Add("content-type", "text/plain");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Content-Type").Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Set Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Set_NewKey_AddsHeader()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
headers.Set("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Set_ExistingKey_ReplacesValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "text/plain");
|
||||
headers.Add("Content-Type", "text/html");
|
||||
|
||||
// Act
|
||||
headers.Set("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Content-Type").Should().BeEquivalentTo(["application/json"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetValues Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetValues_ExistingKey_ReturnsAllValues()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
headers.Add("Accept", "text/html");
|
||||
|
||||
// Act
|
||||
var values = headers.GetValues("Accept");
|
||||
|
||||
// Assert
|
||||
values.Should().BeEquivalentTo(["application/json", "text/plain", "text/html"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetValues_NonexistentKey_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var values = headers.GetValues("X-Missing");
|
||||
|
||||
// Assert
|
||||
values.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetValues_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.GetValues("accept").Should().Contain("application/json");
|
||||
headers.GetValues("ACCEPT").Should().Contain("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryGetValue Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryGetValue_ExistingKey_ReturnsTrueAndValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("Content-Type", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryGetValue_NonexistentKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("X-Missing", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryGetValue_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("content-type", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ContainsKey Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ContainsKey_ExistingKey_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("Content-Type").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ContainsKey_NonexistentKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("X-Missing").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ContainsKey_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("content-type").Should().BeTrue();
|
||||
headers.ContainsKey("CONTENT-TYPE").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enumeration Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetEnumerator_EnumeratesAllHeaderValues()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
headers.Add("Accept", "text/html");
|
||||
|
||||
// Act
|
||||
var list = headers.ToList();
|
||||
|
||||
// Assert
|
||||
list.Should().HaveCount(3);
|
||||
list.Should().Contain(kvp => kvp.Key == "Content-Type" && kvp.Value == "application/json");
|
||||
list.Should().Contain(kvp => kvp.Key == "Accept" && kvp.Value == "text/plain");
|
||||
list.Should().Contain(kvp => kvp.Key == "Accept" && kvp.Value == "text/html");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetEnumerator_EmptyCollection_EnumeratesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var list = headers.ToList();
|
||||
|
||||
// Assert
|
||||
list.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InflightRequestTracker"/>.
|
||||
/// </summary>
|
||||
public sealed class InflightRequestTrackerTests : IDisposable
|
||||
{
|
||||
private readonly InflightRequestTracker _tracker;
|
||||
|
||||
public InflightRequestTrackerTests()
|
||||
{
|
||||
_tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracker.Dispose();
|
||||
}
|
||||
|
||||
#region Track Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Track_NewRequest_ReturnsNonCancelledToken()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var token = _tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
token.IsCancellationRequested.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Track_NewRequest_IncreasesCount()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Track_MultipleRequests_TracksAll()
|
||||
{
|
||||
// Arrange & Act
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Track_DuplicateCorrelationId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage($"*{correlationId}*already being tracked*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Track_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
// Arrange
|
||||
_tracker.Dispose();
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancel Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Cancel_TrackedRequest_CancelsToken()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
var token = _tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
token.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Cancel_UntrackedRequest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Cancel_WithNullReason_Works()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Cancel_CompletedRequest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
_tracker.Complete(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Complete_TrackedRequest_RemovesFromTracking()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
_tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Complete_UntrackedRequest_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Complete_MultipleCompletions_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
_tracker.Complete(correlationId);
|
||||
_tracker.Complete(correlationId);
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CancelAll Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CancelAll_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
var token1 = _tracker.Track(Guid.NewGuid());
|
||||
var token2 = _tracker.Track(Guid.NewGuid());
|
||||
var token3 = _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.CancelAll("Shutdown");
|
||||
|
||||
// Assert
|
||||
token1.IsCancellationRequested.Should().BeTrue();
|
||||
token2.IsCancellationRequested.Should().BeTrue();
|
||||
token3.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CancelAll_ClearsTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.CancelAll("Shutdown");
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CancelAll_WithNoRequests_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _tracker.CancelAll("Test");
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CancelsAllRequests()
|
||||
{
|
||||
// Arrange
|
||||
var token = _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.Dispose();
|
||||
|
||||
// Assert
|
||||
token.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () =>
|
||||
{
|
||||
_tracker.Dispose();
|
||||
_tracker.Dispose();
|
||||
_tracker.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Count Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Count_InitiallyZero()
|
||||
{
|
||||
// Arrange - use a fresh tracker
|
||||
using var tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
using StellaOps.TestKit;
|
||||
// Assert
|
||||
tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Count_ReflectsActiveRequests()
|
||||
{
|
||||
// Arrange
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
_tracker.Track(id1);
|
||||
_tracker.Track(id2);
|
||||
_tracker.Complete(id1);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RawRequestContext"/>.
|
||||
/// </summary>
|
||||
public sealed class RawRequestContextTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Method_DefaultsToEmptyString()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Method.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Path_DefaultsToEmptyString()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Path.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_PathParameters_DefaultsToEmptyDictionary()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.PathParameters.Should().NotBeNull();
|
||||
context.PathParameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Headers_DefaultsToEmptyCollection()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Body_DefaultsToStreamNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_CancellationToken_DefaultsToNone()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.Should().Be(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_CorrelationId_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.CorrelationId.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Initialization Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Method_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { Method = "POST" };
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Path_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { Path = "/api/users/123" };
|
||||
|
||||
// Assert
|
||||
context.Path.Should().Be("/api/users/123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathParameters_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["id"] = "123",
|
||||
["action"] = "update"
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { PathParameters = parameters };
|
||||
|
||||
// Assert
|
||||
context.PathParameters.Should().HaveCount(2);
|
||||
context.PathParameters["id"].Should().Be("123");
|
||||
context.PathParameters["action"].Should().Be("update");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Headers_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
headers.Add("Authorization", "Bearer token");
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { Headers = headers };
|
||||
|
||||
// Assert
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Headers["Authorization"].Should().Be("Bearer token");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Body_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var body = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { Body = body };
|
||||
|
||||
// Assert
|
||||
context.Body.Should().BeSameAs(body);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CancellationToken_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { CancellationToken = cts.Token };
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.Should().Be(cts.Token);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CorrelationId_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { CorrelationId = "req-12345" };
|
||||
|
||||
// Assert
|
||||
context.CorrelationId.Should().Be("req-12345");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Context Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompleteContext_AllPropertiesSet_Works()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
var body = new MemoryStream([123, 125]); // "{}"
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users/{id}",
|
||||
PathParameters = new Dictionary<string, string> { ["id"] = "456" },
|
||||
Headers = headers,
|
||||
Body = body,
|
||||
CancellationToken = cts.Token,
|
||||
CorrelationId = "corr-789"
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
context.Path.Should().Be("/api/users/{id}");
|
||||
context.PathParameters["id"].Should().Be("456");
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Body.Should().BeSameAs(body);
|
||||
context.CancellationToken.Should().Be(cts.Token);
|
||||
context.CorrelationId.Should().Be("corr-789");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Context_WithCancelledToken_HasCancellationRequested()
|
||||
{
|
||||
// Arrange
|
||||
using var cts = new CancellationTokenSource();
|
||||
using StellaOps.TestKit;
|
||||
cts.Cancel();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { CancellationToken = cts.Token };
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Typical Use Case Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TypicalGetRequest_HasMinimalProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/health"
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("GET");
|
||||
context.Path.Should().Be("/api/health");
|
||||
context.Body.Should().BeSameAs(Stream.Null);
|
||||
context.Headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TypicalPostRequest_HasBodyAndHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("Content-Type", "application/json");
|
||||
var body = new MemoryStream([123, 34, 110, 97, 109, 101, 34, 58, 34, 116, 101, 115, 116, 34, 125]); // {"name":"test"}
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
Headers = headers,
|
||||
Body = body
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Body.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RawResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class RawResponseTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_StatusCode_DefaultsTo200()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Headers_DefaultsToEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Body_DefaultsToStreamNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ok Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Ok_WithStream_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(stream);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Should().BeSameAs(stream);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Ok_WithByteArray_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var data = new byte[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(data);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Should().BeOfType<MemoryStream>();
|
||||
((MemoryStream)response.Body).ToArray().Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Ok_WithString_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var text = "Hello, World!";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(text);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(text);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Ok_WithEmptyString_CreatesOkResponse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Ok("");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Length.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NoContent Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NoContent_Creates204Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(204);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NoContent_HasDefaultHeaders()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NoContent_HasDefaultBody()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BadRequest Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BadRequest_Creates400Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(400);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BadRequest_WithDefaultMessage_HasBadRequestText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Bad Request");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BadRequest_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest("Invalid input");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Invalid input");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BadRequest_SetsTextPlainContentType()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotFound Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NotFound_Creates404Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(404);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NotFound_WithDefaultMessage_HasNotFoundText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Not Found");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NotFound_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound("Resource does not exist");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Resource does not exist");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InternalError Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InternalError_Creates500Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(500);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InternalError_WithDefaultMessage_HasInternalServerErrorText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Internal Server Error");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InternalError_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError("Database connection failed");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Database connection failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Factory Method Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(400, "Bad Request")]
|
||||
[InlineData(401, "Unauthorized")]
|
||||
[InlineData(403, "Forbidden")]
|
||||
[InlineData(404, "Not Found")]
|
||||
[InlineData(500, "Internal Server Error")]
|
||||
[InlineData(502, "Bad Gateway")]
|
||||
[InlineData(503, "Service Unavailable")]
|
||||
public void Error_CreatesResponseWithCorrectStatusCode(int statusCode, string message)
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Error(statusCode, message);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(statusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Error_SetsCorrectContentType()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Error(418, "I'm a teapot");
|
||||
|
||||
// Assert
|
||||
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Error_SetsMessageInBody()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Custom error message";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Error(400, message);
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(message);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Error_WithUnicodeMessage_EncodesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Error: \u4e2d\u6587\u6d88\u606f";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Error(400, message);
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
using StellaOps.TestKit;
|
||||
reader.ReadToEnd().Should().Be(message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Initialization Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StatusCode_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse { StatusCode = 201 };
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(201);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Headers_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("X-Custom", "value");
|
||||
|
||||
// Act
|
||||
var response = new RawResponse { Headers = headers };
|
||||
|
||||
// Assert
|
||||
response.Headers["X-Custom"].Should().Be("value");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Body_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
|
||||
// Act
|
||||
var response = new RawResponse { Body = stream };
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(stream);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConnectionManager"/>.
|
||||
/// </summary>
|
||||
public sealed class RouterConnectionManagerTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IEndpointDiscoveryProvider> _discoveryProviderMock;
|
||||
private readonly Mock<IMicroserviceTransport> _transportMock;
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
|
||||
public RouterConnectionManagerTests()
|
||||
{
|
||||
_discoveryProviderMock = new Mock<IEndpointDiscoveryProvider>();
|
||||
_transportMock = new Mock<IMicroserviceTransport>();
|
||||
_options = new StellaMicroserviceOptions
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "test",
|
||||
InstanceId = "test-instance-1",
|
||||
HeartbeatInterval = TimeSpan.FromMilliseconds(50),
|
||||
ReconnectBackoffInitial = TimeSpan.FromMilliseconds(10),
|
||||
ReconnectBackoffMax = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints())
|
||||
.Returns(new List<EndpointDescriptor>());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
private RouterConnectionManager CreateManager()
|
||||
{
|
||||
return new RouterConnectionManager(
|
||||
Options.Create(_options),
|
||||
_discoveryProviderMock.Object,
|
||||
_transportMock.Object,
|
||||
NullLogger<RouterConnectionManager>.Instance);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_InitializesCorrectly()
|
||||
{
|
||||
// Act
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Assert
|
||||
manager.Connections.Should().BeEmpty();
|
||||
manager.CurrentStatus.Should().Be(InstanceHealthStatus.Healthy);
|
||||
manager.InFlightRequestCount.Should().Be(0);
|
||||
manager.ErrorRate.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CurrentStatus Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CurrentStatus_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
manager.CurrentStatus = InstanceHealthStatus.Draining;
|
||||
|
||||
// Assert
|
||||
manager.CurrentStatus.Should().Be(InstanceHealthStatus.Draining);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(InstanceHealthStatus.Healthy)]
|
||||
[InlineData(InstanceHealthStatus.Degraded)]
|
||||
[InlineData(InstanceHealthStatus.Draining)]
|
||||
[InlineData(InstanceHealthStatus.Unhealthy)]
|
||||
public void CurrentStatus_AcceptsAllStatusValues(InstanceHealthStatus status)
|
||||
{
|
||||
// Arrange
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
manager.CurrentStatus = status;
|
||||
|
||||
// Assert
|
||||
manager.CurrentStatus.Should().Be(status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InFlightRequestCount Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InFlightRequestCount_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
manager.InFlightRequestCount = 42;
|
||||
|
||||
// Assert
|
||||
manager.InFlightRequestCount.Should().Be(42);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ErrorRate Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ErrorRate_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
manager.ErrorRate = 0.25;
|
||||
|
||||
// Assert
|
||||
manager.ErrorRate.Should().Be(0.25);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StartAsync Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_DiscoversEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(10);
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_discoveryProviderMock.Verify(d => d.DiscoverEndpoints(), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_WithRouters_CreatesConnections()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
manager.Connections.Should().HaveCount(1);
|
||||
manager.Connections[0].Instance.ServiceName.Should().Be("test-service");
|
||||
|
||||
// Cleanup
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_RegistersEndpointsInConnection()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
|
||||
var endpoints = new List<EndpointDescriptor>
|
||||
{
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "GET", Path = "/api/users" },
|
||||
new() { ServiceName = "test", Version = "1.0", Method = "POST", Path = "/api/users" }
|
||||
};
|
||||
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(endpoints);
|
||||
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
manager.Connections[0].Endpoints.Should().HaveCount(2);
|
||||
|
||||
// Cleanup
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
// Arrange
|
||||
var manager = CreateManager();
|
||||
manager.Dispose();
|
||||
|
||||
// Act
|
||||
var action = () => manager.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await action.Should().ThrowAsync<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StopAsync Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StopAsync_ClearsConnections()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
using var manager = CreateManager();
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
manager.Connections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Heartbeat Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Heartbeat_SendsViaTransport()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(150); // Wait for heartbeat to run
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_transportMock.Verify(
|
||||
t => t.SendHeartbeatAsync(It.IsAny<HeartbeatPayload>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Heartbeat_IncludesCurrentMetrics()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
using var manager = CreateManager();
|
||||
using StellaOps.TestKit;
|
||||
manager.CurrentStatus = InstanceHealthStatus.Degraded;
|
||||
manager.InFlightRequestCount = 10;
|
||||
manager.ErrorRate = 0.05;
|
||||
|
||||
HeartbeatPayload? capturedHeartbeat = null;
|
||||
_transportMock.Setup(t => t.SendHeartbeatAsync(It.IsAny<HeartbeatPayload>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<HeartbeatPayload, CancellationToken>((h, _) => capturedHeartbeat = h)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(150); // Wait for heartbeat
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
capturedHeartbeat.Should().NotBeNull();
|
||||
capturedHeartbeat!.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
capturedHeartbeat.InFlightRequestCount.Should().Be(10);
|
||||
capturedHeartbeat.ErrorRate.Should().Be(0.05);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
manager.Dispose();
|
||||
manager.Dispose();
|
||||
manager.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Microservice.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure since not needed for Microservice SDK tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,251 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Microservice.Validation;
|
||||
|
||||
namespace StellaOps.Microservice.Tests.Validation;
|
||||
|
||||
public class RequestSchemaValidatorTests
|
||||
{
|
||||
private readonly IRequestSchemaValidator _validator;
|
||||
|
||||
public RequestSchemaValidatorTests()
|
||||
{
|
||||
_validator = new RequestSchemaValidator(NullLogger<RequestSchemaValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ValidDocument_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""name"": { ""type"": ""string"" }
|
||||
},
|
||||
""required"": [""name""]
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""name"": ""John"" }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_MissingRequiredProperty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""name"": { ""type"": ""string"" }
|
||||
},
|
||||
""required"": [""name""]
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{}");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
errors.Should().Contain(e => e.Keyword == "required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_WrongType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""age"": { ""type"": ""integer"" }
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""age"": ""not a number"" }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
errors.Should().Contain(e => e.Keyword == "type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_AdditionalProperties_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""name"": { ""type"": ""string"" }
|
||||
},
|
||||
""additionalProperties"": false
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""name"": ""John"", ""extra"": ""field"" }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_NestedObject_ValidatesRecursively()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""address"": {
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""city"": { ""type"": ""string"" }
|
||||
},
|
||||
""required"": [""city""]
|
||||
}
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""address"": {} }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_Array_ValidatesItems()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""items"": {
|
||||
""type"": ""array"",
|
||||
""items"": { ""type"": ""integer"" }
|
||||
}
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""items"": [1, ""two"", 3] }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_NullableProperty_AllowsNull()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""name"": { ""type"": [""string"", ""null""] }
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""name"": null }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_MinimumConstraint_Validates()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""age"": { ""type"": ""integer"", ""minimum"": 0 }
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""age"": -5 }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
errors.Should().NotBeEmpty();
|
||||
errors.Should().Contain(e => e.Keyword == "minimum");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_StringFormat_Validates()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""email"": { ""type"": ""string"", ""format"": ""email"" }
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""email"": ""not-an-email"" }");
|
||||
|
||||
// Act - Note: format validation is typically not strict by default in JsonSchema.Net
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert - Just verify we get a result without throwing
|
||||
// Format validation depends on configuration
|
||||
(isValid || !isValid).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_EmptyObject_AgainstEmptySchema_IsValid()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{ ""type"": ""object"" }");
|
||||
var doc = JsonDocument.Parse(@"{}");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ErrorContainsInstanceLocation()
|
||||
{
|
||||
// Arrange
|
||||
var schema = JsonSchema.FromText(@"{
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""items"": {
|
||||
""type"": ""array"",
|
||||
""items"": { ""type"": ""integer"" }
|
||||
}
|
||||
}
|
||||
}");
|
||||
var doc = JsonDocument.Parse(@"{ ""items"": [1, ""bad"", 3] }");
|
||||
|
||||
// Act
|
||||
var isValid = _validator.TryValidate(doc, schema, out var errors);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
var error = errors.First();
|
||||
error.InstanceLocation.Should().NotBeNullOrEmpty();
|
||||
error.SchemaLocation.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Microservice.Validation;
|
||||
|
||||
namespace StellaOps.Microservice.Tests.Validation;
|
||||
|
||||
public class SchemaRegistryTests
|
||||
{
|
||||
private static readonly string SimpleSchema = @"{
|
||||
""$schema"": ""https://json-schema.org/draft/2020-12/schema"",
|
||||
""type"": ""object"",
|
||||
""properties"": {
|
||||
""name"": { ""type"": ""string"" },
|
||||
""age"": { ""type"": ""integer"" }
|
||||
},
|
||||
""required"": [""name""],
|
||||
""additionalProperties"": false
|
||||
}";
|
||||
|
||||
[Fact]
|
||||
public void GetRequestSchema_WithNoProvider_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance);
|
||||
|
||||
// Act
|
||||
var schema = registry.GetRequestSchema("POST", "/test");
|
||||
|
||||
// Assert
|
||||
schema.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequestSchema_WithProvider_ReturnsCompiledSchema()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var schema = registry.GetRequestSchema("POST", "/test");
|
||||
|
||||
// Assert
|
||||
schema.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequestSchema_IsCached()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var schema1 = registry.GetRequestSchema("POST", "/test");
|
||||
var schema2 = registry.GetRequestSchema("POST", "/test");
|
||||
|
||||
// Assert
|
||||
schema1.Should().BeSameAs(schema2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSchema_WithValidSchema_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act & Assert
|
||||
registry.HasSchema("POST", "/test", SchemaDirection.Request).Should().BeTrue();
|
||||
registry.HasSchema("POST", "/test", SchemaDirection.Response).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSchema_WithMissingEndpoint_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance);
|
||||
|
||||
// Act & Assert
|
||||
registry.HasSchema("POST", "/nonexistent", SchemaDirection.Request).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSchemaText_ReturnsOriginalSchemaJson()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var schemaText = registry.GetSchemaText("POST", "/test", SchemaDirection.Request);
|
||||
|
||||
// Assert
|
||||
schemaText.Should().Be(SimpleSchema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSchemaETag_ReturnsConsistentETag()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var etag1 = registry.GetSchemaETag("POST", "/test", SchemaDirection.Request);
|
||||
var etag2 = registry.GetSchemaETag("POST", "/test", SchemaDirection.Request);
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBeNullOrEmpty();
|
||||
etag1.Should().Be(etag2);
|
||||
etag1.Should().StartWith("\"").And.EndWith("\""); // ETag format
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllSchemas_ReturnsAllDefinitions()
|
||||
{
|
||||
// Arrange
|
||||
var definitions = new[]
|
||||
{
|
||||
new EndpointSchemaDefinition("POST", "/a", SimpleSchema, null, true, false),
|
||||
new EndpointSchemaDefinition("GET", "/b", null, SimpleSchema, false, true)
|
||||
};
|
||||
var provider = new TestSchemaProvider(definitions);
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var all = registry.GetAllSchemas();
|
||||
|
||||
// Assert
|
||||
all.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequestSchema_MethodIsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSchemaProvider(new[]
|
||||
{
|
||||
new EndpointSchemaDefinition(
|
||||
"POST",
|
||||
"/test",
|
||||
SimpleSchema,
|
||||
null,
|
||||
true,
|
||||
false)
|
||||
});
|
||||
var registry = new SchemaRegistry(NullLogger<SchemaRegistry>.Instance, provider);
|
||||
|
||||
// Act
|
||||
var schema = registry.GetRequestSchema("post", "/test");
|
||||
|
||||
// Assert
|
||||
schema.Should().NotBeNull();
|
||||
}
|
||||
|
||||
private sealed class TestSchemaProvider : IGeneratedSchemaProvider
|
||||
{
|
||||
private readonly IReadOnlyList<EndpointSchemaDefinition> _definitions;
|
||||
|
||||
public TestSchemaProvider(IReadOnlyList<EndpointSchemaDefinition> definitions)
|
||||
{
|
||||
_definitions = definitions;
|
||||
}
|
||||
|
||||
public IReadOnlyList<EndpointSchemaDefinition> GetSchemaDefinitions() => _definitions;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Microservice.Validation;
|
||||
|
||||
namespace StellaOps.Microservice.Tests.Validation;
|
||||
|
||||
public class ValidationProblemDetailsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_RequestValidation_SetsCorrectDetail()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>
|
||||
{
|
||||
new("/name", "#/properties/name", "Name is required", "required")
|
||||
};
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/invoices",
|
||||
SchemaDirection.Request,
|
||||
errors,
|
||||
"test-correlation-id");
|
||||
|
||||
// Assert
|
||||
details.Detail.Should().Contain("Request");
|
||||
details.Detail.Should().Contain("POST");
|
||||
details.Detail.Should().Contain("/invoices");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ResponseValidation_SetsCorrectDetail()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"GET",
|
||||
"/items",
|
||||
SchemaDirection.Response,
|
||||
errors);
|
||||
|
||||
// Assert
|
||||
details.Detail.Should().Contain("Response");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsCorrectStatus()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Assert
|
||||
details.Status.Should().Be(422);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Assert
|
||||
details.Type.Should().Be("https://stellaops.io/errors/schema-validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsInstance()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/api/v1/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Assert
|
||||
details.Instance.Should().Be("/api/v1/test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SetsTraceId()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors,
|
||||
"trace-123");
|
||||
|
||||
// Assert
|
||||
details.TraceId.Should().Be("trace-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_IncludesAllErrors()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>
|
||||
{
|
||||
new("/name", "#/properties/name", "Name is required", "required"),
|
||||
new("/age", "#/properties/age/type", "Expected integer", "type")
|
||||
};
|
||||
|
||||
// Act
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Assert
|
||||
details.Errors.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRawResponse_Returns422StatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Act
|
||||
var response = details.ToRawResponse();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(422);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRawResponse_SetsProblemJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>();
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors);
|
||||
|
||||
// Act
|
||||
var response = details.ToRawResponse();
|
||||
|
||||
// Assert
|
||||
response.Headers.TryGetValue("Content-Type", out var contentType).Should().BeTrue();
|
||||
contentType.Should().Contain("application/problem+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRawResponse_SerializesAsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>
|
||||
{
|
||||
new("/name", "#/properties/name", "Name is required", "required")
|
||||
};
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors,
|
||||
"trace-123");
|
||||
|
||||
// Act
|
||||
var response = details.ToRawResponse();
|
||||
response.Body.Position = 0;
|
||||
using var reader = new StreamReader(response.Body);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
// Assert
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
parsed.RootElement.GetProperty("type").GetString().Should().Be("https://stellaops.io/errors/schema-validation");
|
||||
parsed.RootElement.GetProperty("status").GetInt32().Should().Be(422);
|
||||
parsed.RootElement.GetProperty("traceId").GetString().Should().Be("trace-123");
|
||||
parsed.RootElement.GetProperty("errors").GetArrayLength().Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRawResponse_UsesCamelCasePropertyNames()
|
||||
{
|
||||
// Arrange
|
||||
var errors = new List<SchemaValidationError>
|
||||
{
|
||||
new("/name", "#/properties/name", "Name is required", "required")
|
||||
};
|
||||
var details = ValidationProblemDetails.Create(
|
||||
"POST",
|
||||
"/test",
|
||||
SchemaDirection.Request,
|
||||
errors,
|
||||
"trace-123");
|
||||
|
||||
// Act
|
||||
var response = details.ToRawResponse();
|
||||
response.Body.Position = 0;
|
||||
using var reader = new StreamReader(response.Body);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"traceId\"");
|
||||
json.Should().Contain("\"instanceLocation\"");
|
||||
json.Should().Contain("\"schemaLocation\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
namespace StellaOps.Plugin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PluginCompatibilityChecker.
|
||||
/// </summary>
|
||||
public sealed class PluginCompatibilityCheckerTests
|
||||
{
|
||||
#region Basic Compatibility Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void CheckCompatibility_AssemblyWithoutAttribute_Lenient_ReturnsCompatible()
|
||||
{
|
||||
// Arrange - Use current test assembly which doesn't have StellaPluginVersionAttribute
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
var hostVersion = new Version(1, 0, 0);
|
||||
|
||||
// Act
|
||||
var result = PluginCompatibilityChecker.CheckCompatibility(
|
||||
assembly, hostVersion, CompatibilityCheckOptions.Lenient);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsCompatible);
|
||||
Assert.False(result.HasVersionAttribute);
|
||||
Assert.Null(result.PluginVersion);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void CheckCompatibility_AssemblyWithoutAttribute_RequireVersion_ReturnsIncompatible()
|
||||
{
|
||||
// Arrange
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
var hostVersion = new Version(1, 0, 0);
|
||||
var options = new CompatibilityCheckOptions
|
||||
{
|
||||
RequireVersionAttribute = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = PluginCompatibilityChecker.CheckCompatibility(assembly, hostVersion, options);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsCompatible);
|
||||
Assert.False(result.HasVersionAttribute);
|
||||
Assert.NotNull(result.FailureReason);
|
||||
Assert.Contains("StellaPluginVersion", result.FailureReason);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void CheckCompatibility_NullAssembly_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var hostVersion = new Version(1, 0, 0);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => PluginCompatibilityChecker.CheckCompatibility(null!, hostVersion));
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void CheckCompatibility_NullHostVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => PluginCompatibilityChecker.CheckCompatibility(assembly, null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CompatibilityCheckOptions Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void DefaultOptions_HasCorrectValues()
|
||||
{
|
||||
// Act
|
||||
var options = CompatibilityCheckOptions.Default;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.RequireVersionAttribute);
|
||||
Assert.True(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LenientOptions_HasCorrectValues()
|
||||
{
|
||||
// Act
|
||||
var options = CompatibilityCheckOptions.Lenient;
|
||||
|
||||
// Assert
|
||||
Assert.False(options.RequireVersionAttribute);
|
||||
Assert.False(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void CustomOptions_CanBeConfigured()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new CompatibilityCheckOptions
|
||||
{
|
||||
RequireVersionAttribute = true,
|
||||
StrictMajorVersionCheck = false
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.True(options.RequireVersionAttribute);
|
||||
Assert.False(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PluginCompatibilityResult Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void PluginCompatibilityResult_RecordEquality()
|
||||
{
|
||||
// Arrange
|
||||
var result1 = new PluginCompatibilityResult(
|
||||
IsCompatible: true,
|
||||
PluginVersion: new Version(1, 0, 0),
|
||||
MinimumHostVersion: new Version(1, 0, 0),
|
||||
MaximumHostVersion: new Version(2, 0, 0),
|
||||
RequiresSignature: true,
|
||||
FailureReason: null,
|
||||
HasVersionAttribute: true);
|
||||
|
||||
var result2 = new PluginCompatibilityResult(
|
||||
IsCompatible: true,
|
||||
PluginVersion: new Version(1, 0, 0),
|
||||
MinimumHostVersion: new Version(1, 0, 0),
|
||||
MaximumHostVersion: new Version(2, 0, 0),
|
||||
RequiresSignature: true,
|
||||
FailureReason: null,
|
||||
HasVersionAttribute: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1, result2);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void PluginCompatibilityResult_PropertiesAreSet()
|
||||
{
|
||||
// Arrange
|
||||
var pluginVersion = new Version(1, 2, 3);
|
||||
var minVersion = new Version(1, 0, 0);
|
||||
var maxVersion = new Version(2, 0, 0);
|
||||
var failureReason = "Test failure";
|
||||
|
||||
// Act
|
||||
var result = new PluginCompatibilityResult(
|
||||
IsCompatible: false,
|
||||
PluginVersion: pluginVersion,
|
||||
MinimumHostVersion: minVersion,
|
||||
MaximumHostVersion: maxVersion,
|
||||
RequiresSignature: true,
|
||||
FailureReason: failureReason,
|
||||
HasVersionAttribute: true);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsCompatible);
|
||||
Assert.Equal(pluginVersion, result.PluginVersion);
|
||||
Assert.Equal(minVersion, result.MinimumHostVersion);
|
||||
Assert.Equal(maxVersion, result.MaximumHostVersion);
|
||||
Assert.True(result.RequiresSignature);
|
||||
Assert.Equal(failureReason, result.FailureReason);
|
||||
Assert.True(result.HasVersionAttribute);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Plugin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PluginHostOptions configuration.
|
||||
/// </summary>
|
||||
public sealed class PluginHostOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Assert
|
||||
Assert.True(options.EnsureDirectoryExists);
|
||||
Assert.True(options.RecursiveSearch); // Default is true
|
||||
Assert.False(options.EnforceSignatureVerification);
|
||||
Assert.True(options.RequireVersionAttribute); // Default is true
|
||||
Assert.True(options.EnforceVersionCompatibility); // Default is true
|
||||
Assert.True(options.StrictMajorVersionCheck); // Default is true
|
||||
Assert.Null(options.HostVersion);
|
||||
Assert.Null(options.SignatureVerifier);
|
||||
Assert.Empty(options.SearchPatterns);
|
||||
Assert.Empty(options.PluginOrder);
|
||||
Assert.Empty(options.AdditionalPrefixes);
|
||||
Assert.Null(options.BaseDirectory);
|
||||
Assert.Null(options.PluginsDirectory);
|
||||
Assert.Null(options.PrimaryPrefix);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Search Pattern Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void SearchPatterns_CanBeAdded()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.SearchPatterns.Add("MyPrefix.Plugin.*.dll");
|
||||
options.SearchPatterns.Add("Another.Plugin.*.dll");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, options.SearchPatterns.Count);
|
||||
Assert.Contains("MyPrefix.Plugin.*.dll", options.SearchPatterns);
|
||||
Assert.Contains("Another.Plugin.*.dll", options.SearchPatterns);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plugin Order Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void PluginOrder_CanBeConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.PluginOrder.Add("PluginA");
|
||||
options.PluginOrder.Add("PluginB");
|
||||
options.PluginOrder.Add("PluginC");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, options.PluginOrder.Count);
|
||||
Assert.Equal("PluginA", options.PluginOrder[0]);
|
||||
Assert.Equal("PluginB", options.PluginOrder[1]);
|
||||
Assert.Equal("PluginC", options.PluginOrder[2]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BaseDirectory Resolution Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void ResolveBaseDirectory_WhenNull_ReturnsAppContextBaseDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var resolved = options.ResolveBaseDirectory();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(AppContext.BaseDirectory, resolved);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void ResolveBaseDirectory_WhenSet_ReturnsConfiguredValue()
|
||||
{
|
||||
// Arrange
|
||||
var customDir = Path.GetTempPath();
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = customDir
|
||||
};
|
||||
|
||||
// Act
|
||||
var resolved = options.ResolveBaseDirectory();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(customDir, resolved);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Configuration Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void HostVersion_CanBeConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.HostVersion = new Version(2, 1, 0);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(options.HostVersion);
|
||||
Assert.Equal(2, options.HostVersion.Major);
|
||||
Assert.Equal(1, options.HostVersion.Minor);
|
||||
Assert.Equal(0, options.HostVersion.Build);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void VersionCompatibility_CanBeEnforced()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.HostVersion = new Version(1, 0, 0);
|
||||
options.RequireVersionAttribute = true;
|
||||
options.EnforceVersionCompatibility = true;
|
||||
options.StrictMajorVersionCheck = true;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.RequireVersionAttribute);
|
||||
Assert.True(options.EnforceVersionCompatibility);
|
||||
Assert.True(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prefix Configuration Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void PrimaryPrefix_AffectsDefaultDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PrimaryPrefix = "MyModule"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("MyModule", options.PrimaryPrefix);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void AdditionalPrefixes_CanBeConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.AdditionalPrefixes.Add("Prefix1");
|
||||
options.AdditionalPrefixes.Add("Prefix2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, options.AdditionalPrefixes.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Signature Verification Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void SignatureVerification_CanBeEnforced()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.EnforceSignatureVerification = true;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.EnforceSignatureVerification);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Recursive Search Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void RecursiveSearch_CanBeEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// Act
|
||||
options.RecursiveSearch = true;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.RecursiveSearch);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
namespace StellaOps.Plugin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PluginHost plugin loading.
|
||||
/// </summary>
|
||||
public sealed class PluginHostTests
|
||||
{
|
||||
#region LoadPlugins Directory Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_NonExistentDirectory_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "plugins"),
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Plugins);
|
||||
Assert.Empty(result.Failures);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_EmptyDirectory_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = tempDir,
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Plugins);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_EnsureDirectoryExists_CreatesDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "plugins");
|
||||
Assert.False(Directory.Exists(tempDir));
|
||||
|
||||
try
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = tempDir,
|
||||
EnsureDirectoryExists = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
// Assert
|
||||
Assert.True(Directory.Exists(tempDir));
|
||||
Assert.Empty(result.Plugins);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
|
||||
var parent = Path.GetDirectoryName(tempDir);
|
||||
if (parent is not null && Directory.Exists(parent))
|
||||
{
|
||||
Directory.Delete(parent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_NullOptions_ThrowsException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => PluginHost.LoadPlugins(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Search Pattern Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_CustomSearchPattern_RespectsPattern()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// Create a file that won't match the pattern
|
||||
File.WriteAllText(Path.Combine(tempDir, "NotAPlugin.dll"), "dummy");
|
||||
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = tempDir,
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
options.SearchPatterns.Add("MyPrefix.Plugin.*.dll");
|
||||
|
||||
// Act
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Plugins);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plugin Order Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadPlugins_WithPluginOrder_AcceptsOrderConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = tempDir,
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
options.PluginOrder.Add("PluginA");
|
||||
options.PluginOrder.Add("PluginB");
|
||||
|
||||
// Act
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
// Assert - In an empty directory, no plugins are loaded
|
||||
Assert.Empty(result.Plugins);
|
||||
Assert.NotNull(result.MissingOrderedPlugins);
|
||||
// MissingOrderedPlugins only tracks plugins that were in PluginOrder but not found in discovered files
|
||||
// In an empty directory, no files are discovered, so the list may be empty
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async Tests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task LoadPluginsAsync_NonExistentDirectory_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "plugins"),
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await PluginHost.LoadPluginsAsync(options);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Plugins);
|
||||
Assert.Empty(result.Failures);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task LoadPluginsAsync_WithCancellationToken_AcceptsToken()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = tempDir,
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
// Don't cancel - just verify the async method accepts a cancellation token
|
||||
|
||||
// Act
|
||||
var result = await PluginHost.LoadPluginsAsync(options, cancellationToken: cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Plugins);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,19 +4,25 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentAssertions;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache;
|
||||
using Xunit;
|
||||
|
||||
@@ -259,7 +259,6 @@ public sealed class EvidenceChunkerTests
|
||||
var evidence = new byte[200];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
using var stream = new MemoryStream(evidence);
|
||||
using StellaOps.TestKit;
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentAssertions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -223,7 +223,6 @@ public sealed class MinimalProofExporterTests
|
||||
var options = new MinimalProofExportOptions { Density = ProofDensity.Lite };
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
// Act
|
||||
await _exporter.ExportToStreamAsync(_testEntry.VeriKey, options, stream);
|
||||
|
||||
|
||||
@@ -49,14 +49,13 @@ public sealed class ProvcacheOciAttestationBuilderTests
|
||||
Sbom = new SbomInfo
|
||||
{
|
||||
Hash = "sha256:sbom123",
|
||||
Format = "cyclonedx",
|
||||
Version = "1.6",
|
||||
Format = "cyclonedx-1.6",
|
||||
PackageCount = 42,
|
||||
CompletenessScore = 95
|
||||
},
|
||||
Vex = new VexInfo
|
||||
{
|
||||
SetHash = "sha256:vex123",
|
||||
HashSetHash = "sha256:vex123",
|
||||
StatementCount = 5,
|
||||
Sources = ["vendor", "osv"]
|
||||
},
|
||||
@@ -65,7 +64,7 @@ public sealed class ProvcacheOciAttestationBuilderTests
|
||||
Hash = "sha256:policy123",
|
||||
Name = "default-policy",
|
||||
PackId = "stellaops-base",
|
||||
Version = "v2.0"
|
||||
Version = 2
|
||||
},
|
||||
Signers = new SignerInfo
|
||||
{
|
||||
@@ -76,8 +75,8 @@ public sealed class ProvcacheOciAttestationBuilderTests
|
||||
TimeWindow = new TimeWindowInfo
|
||||
{
|
||||
Bucket = "2025-01-01",
|
||||
StartTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
EndTime = new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero)
|
||||
StartsAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
EndsAt = new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,7 +226,7 @@ public sealed class ProvcacheOciAttestationBuilderTests
|
||||
var inputSummary = result.Statement.Predicate.InputManifest;
|
||||
inputSummary.SourceHash.Should().Be(manifest.SourceArtifact.Digest);
|
||||
inputSummary.SbomHash.Should().Be(manifest.Sbom.Hash);
|
||||
inputSummary.VexSetHash.Should().Be(manifest.Vex.SetHash);
|
||||
inputSummary.VexSetHash.Should().Be(manifest.Vex.HashSetHash);
|
||||
inputSummary.PolicyHash.Should().Be(manifest.Policy.Hash);
|
||||
inputSummary.SignerSetHash.Should().Be(manifest.Signers.SetHash);
|
||||
}
|
||||
|
||||
@@ -11,19 +11,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="8.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,5 +25,4 @@
|
||||
<ProjectReference Include="../../StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using FluentAssertions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
@@ -139,7 +139,6 @@ public class WriteBehindQueueTests
|
||||
|
||||
// Act - Start the queue and let it process
|
||||
using var cts = new CancellationTokenSource();
|
||||
using StellaOps.TestKit;
|
||||
var task = queue.StartAsync(cts.Token);
|
||||
|
||||
// Wait for processing
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Provenance;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provenance.Tests;
|
||||
|
||||
public sealed class ProvenanceExtensionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AttachDsseProvenance_WritesNestedDocuments()
|
||||
{
|
||||
var document = new DocumentObject
|
||||
{
|
||||
{ "kind", "VEX" },
|
||||
{ "subject", new DocumentObject("digest", new DocumentObject("sha256", "sha256:abc")) }
|
||||
};
|
||||
|
||||
var dsse = new DsseProvenance
|
||||
{
|
||||
EnvelopeDigest = "sha256:deadbeef",
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Key = new DsseKeyInfo
|
||||
{
|
||||
KeyId = "cosign:SHA256-PKIX:TEST",
|
||||
Issuer = "fulcio",
|
||||
Algo = "ECDSA"
|
||||
},
|
||||
Rekor = new DsseRekorInfo
|
||||
{
|
||||
LogIndex = 123,
|
||||
Uuid = Guid.Parse("2d4d5f7c-1111-4a01-b9cb-aa42022a0a8c").ToString(),
|
||||
IntegratedTime = 1_699_999_999,
|
||||
MirrorSeq = 10
|
||||
},
|
||||
Chain = new List<DsseChainLink>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "build",
|
||||
Id = "att:build#1",
|
||||
Digest = "sha256:chain"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var trust = new TrustInfo
|
||||
{
|
||||
Verified = true,
|
||||
Verifier = "Authority@stella",
|
||||
Witnesses = 2,
|
||||
PolicyScore = 0.9
|
||||
};
|
||||
|
||||
document.AttachDsseProvenance(dsse, trust);
|
||||
|
||||
var provenanceDoc = (DocumentObject)((DocumentObject)document["provenance"])["dsse"];
|
||||
Assert.Equal("sha256:deadbeef", ((DocumentString)provenanceDoc["envelopeDigest"]).Value);
|
||||
Assert.Equal(123, ((DocumentInt64)((DocumentObject)provenanceDoc["rekor"])["logIndex"]).Value);
|
||||
Assert.Equal("att:build#1", ((DocumentString)((DocumentObject)((DocumentArray)provenanceDoc["chain"])[0])["id"]).Value);
|
||||
|
||||
var trustDoc = (DocumentObject)document["trust"];
|
||||
Assert.True((bool)((DocumentBoolean)trustDoc["verified"]).Value!);
|
||||
Assert.Equal(2, ((DocumentInt32)trustDoc["witnesses"]).Value);
|
||||
Assert.Equal(0.9, ((DocumentDouble)trustDoc["policyScore"]).Value);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildProvenVexFilter_TargetsKindSubjectAndVerified()
|
||||
{
|
||||
var filter = ProvenanceExtensions.BuildProvenVexFilter("VEX", "sha256:123");
|
||||
|
||||
Assert.Equal("VEX", ((DocumentString)filter["kind"]).Value);
|
||||
Assert.Equal("sha256:123", ((DocumentString)filter["subject.digest.sha256"]).Value);
|
||||
Assert.True(filter.ContainsKey("provenance.dsse.rekor.logIndex"));
|
||||
Assert.True(filter.ContainsKey("trust.verified"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildUnprovenEvidenceFilter_FlagsMissingTrustOrRekor()
|
||||
{
|
||||
var filter = ProvenanceExtensions.BuildUnprovenEvidenceFilter(new[] { "SBOM", "VEX" });
|
||||
|
||||
var kindClause = (DocumentArray)((DocumentObject)filter["kind"])["$in"];
|
||||
Assert.Contains("SBOM", kindClause.Select(v => ((DocumentString)v).Value));
|
||||
Assert.Contains("VEX", kindClause.Select(v => ((DocumentString)v).Value));
|
||||
|
||||
var orConditions = (DocumentArray)filter["$or"];
|
||||
Assert.Equal(2, orConditions.Count);
|
||||
|
||||
var trustCondition = (DocumentObject)orConditions[0];
|
||||
Assert.Equal("$ne", ((DocumentObject)trustCondition["trust.verified"]).Keys.Single());
|
||||
|
||||
var rekorCondition = (DocumentObject)orConditions[1];
|
||||
Assert.Equal("$exists", ((DocumentObject)rekorCondition["provenance.dsse.rekor.logIndex"]).Keys.Single());
|
||||
Assert.False((bool)((DocumentBoolean)((DocumentObject)rekorCondition["provenance.dsse.rekor.logIndex"])["$exists"]).Value!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Provenance\StellaOps.Provenance.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,293 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.Tests;
|
||||
|
||||
public class CanonicalSerializerTests
|
||||
{
|
||||
private readonly CanonicalReachGraphSerializer _serializer = new();
|
||||
|
||||
[Fact]
|
||||
public void Serialization_WithSameInput_ProducesSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
|
||||
// Act
|
||||
var result1 = _serializer.SerializeMinimal(graph);
|
||||
var result2 = _serializer.SerializeMinimal(graph);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1, result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_NodeOrder_IsLexicographic()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateGraphWithUnorderedNodes();
|
||||
|
||||
// Act
|
||||
var json = _serializer.SerializePretty(graph);
|
||||
var deserialized = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(IsLexicographicallySorted(deserialized.Nodes, n => n.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_EdgeOrder_IsLexicographicByFromThenTo()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateGraphWithUnorderedEdges();
|
||||
|
||||
// Act
|
||||
var json = _serializer.SerializePretty(graph);
|
||||
var deserialized = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
var edges = deserialized.Edges.ToArray();
|
||||
for (var i = 1; i < edges.Length; i++)
|
||||
{
|
||||
var comparison = string.Compare(edges[i - 1].From, edges[i].From, StringComparison.Ordinal);
|
||||
if (comparison == 0)
|
||||
{
|
||||
comparison = string.Compare(edges[i - 1].To, edges[i].To, StringComparison.Ordinal);
|
||||
}
|
||||
Assert.True(comparison <= 0, $"Edges not sorted: {edges[i - 1].From}->{edges[i - 1].To} should come before {edges[i].From}->{edges[i].To}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_NullFields_AreOmitted()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateGraphWithNullFields();
|
||||
|
||||
// Act
|
||||
var json = Encoding.UTF8.GetString(_serializer.SerializeMinimal(graph));
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("\"cves\":null", json);
|
||||
Assert.DoesNotContain("\"signatures\":null", json);
|
||||
Assert.DoesNotContain("\"vex\":null", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_Timestamps_AreUtcIso8601WithMilliseconds()
|
||||
{
|
||||
// Arrange
|
||||
var computedAt = new DateTimeOffset(2025, 12, 27, 10, 30, 45, 123, TimeSpan.Zero);
|
||||
var graph = CreateSampleGraph() with
|
||||
{
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Inputs = new ReachGraphInputs { Sbom = "sha256:abc123" },
|
||||
ComputedAt = computedAt,
|
||||
Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = Encoding.UTF8.GetString(_serializer.SerializeMinimal(graph));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("2025-12-27T10:30:45.123Z", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_PreservesAllData()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateCompleteGraph();
|
||||
|
||||
// Act
|
||||
var bytes = _serializer.SerializeMinimal(original);
|
||||
var restored = _serializer.Deserialize(bytes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(original.SchemaVersion, restored.SchemaVersion);
|
||||
Assert.Equal(original.Artifact.Name, restored.Artifact.Name);
|
||||
Assert.Equal(original.Artifact.Digest, restored.Artifact.Digest);
|
||||
Assert.Equal(original.Scope.Entrypoints.Length, restored.Scope.Entrypoints.Length);
|
||||
Assert.Equal(original.Nodes.Length, restored.Nodes.Length);
|
||||
Assert.Equal(original.Edges.Length, restored.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ValidJson_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "reachgraph.min@v1",
|
||||
"artifact": {"name": "test", "digest": "sha256:abc", "env": ["linux/amd64"]},
|
||||
"scope": {"entrypoints": ["/main"], "selectors": ["prod"]},
|
||||
"nodes": [{"id": "n1", "kind": "function", "ref": "main()"}],
|
||||
"edges": [{"from": "n1", "to": "n2", "why": {"type": "import", "confidence": 1.0}}],
|
||||
"provenance": {
|
||||
"inputs": {"sbom": "sha256:sbom123"},
|
||||
"computedAt": "2025-12-27T10:00:00.000Z",
|
||||
"analyzer": {"name": "test", "version": "1.0.0", "toolchainDigest": "sha256:tc"}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("reachgraph.min@v1", graph.SchemaVersion);
|
||||
Assert.Equal("test", graph.Artifact.Name);
|
||||
Assert.Single(graph.Nodes);
|
||||
Assert.Single(graph.Edges);
|
||||
}
|
||||
|
||||
private static ReachGraphMinimal CreateSampleGraph() => new()
|
||||
{
|
||||
Artifact = new ReachGraphArtifact("test-app", "sha256:abc123", ["linux/amd64"]),
|
||||
Scope = new ReachGraphScope(
|
||||
["/app/main"],
|
||||
["prod"]
|
||||
),
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode { Id = "sha256:001", Kind = ReachGraphNodeKind.Function, Ref = "main()" },
|
||||
new ReachGraphNode { Id = "sha256:002", Kind = ReachGraphNodeKind.Function, Ref = "process()" }
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "sha256:001",
|
||||
To = "sha256:002",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 }
|
||||
}
|
||||
],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Inputs = new ReachGraphInputs { Sbom = "sha256:sbom123" },
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain")
|
||||
}
|
||||
};
|
||||
|
||||
private static ReachGraphMinimal CreateGraphWithUnorderedNodes() => CreateSampleGraph() with
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode { Id = "sha256:zzz", Kind = ReachGraphNodeKind.Function, Ref = "z()" },
|
||||
new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" },
|
||||
new ReachGraphNode { Id = "sha256:mmm", Kind = ReachGraphNodeKind.Function, Ref = "m()" }
|
||||
]
|
||||
};
|
||||
|
||||
private static ReachGraphMinimal CreateGraphWithUnorderedEdges() => CreateSampleGraph() with
|
||||
{
|
||||
Edges =
|
||||
[
|
||||
new ReachGraphEdge { From = "sha256:bbb", To = "sha256:ccc", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } },
|
||||
new ReachGraphEdge { From = "sha256:aaa", To = "sha256:zzz", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } },
|
||||
new ReachGraphEdge { From = "sha256:aaa", To = "sha256:bbb", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } }
|
||||
]
|
||||
};
|
||||
|
||||
private static ReachGraphMinimal CreateGraphWithNullFields() => new()
|
||||
{
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc", []),
|
||||
Scope = new ReachGraphScope([], [], null), // cves is null
|
||||
Nodes = [],
|
||||
Edges = [],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Inputs = new ReachGraphInputs { Sbom = "sha256:sbom" }, // vex, callgraph, etc. are null
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
Analyzer = new ReachGraphAnalyzer("test", "1.0", "sha256:tc")
|
||||
},
|
||||
Signatures = null // signatures is null
|
||||
};
|
||||
|
||||
private static ReachGraphMinimal CreateCompleteGraph() => new()
|
||||
{
|
||||
Artifact = new ReachGraphArtifact("complete-app:v1", "sha256:complete123", ["linux/amd64", "linux/arm64"]),
|
||||
Scope = new ReachGraphScope(
|
||||
["/app/main", "/app/worker"],
|
||||
["prod", "staging"],
|
||||
["CVE-2024-1234", "CVE-2024-5678"]
|
||||
),
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode
|
||||
{
|
||||
Id = "sha256:entry",
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
Ref = "main()",
|
||||
File = "src/main.ts",
|
||||
Line = 1,
|
||||
IsEntrypoint = true
|
||||
},
|
||||
new ReachGraphNode
|
||||
{
|
||||
Id = "sha256:sink",
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
Ref = "vulnerable()",
|
||||
File = "node_modules/vuln/index.js",
|
||||
Line = 50,
|
||||
IsSink = true
|
||||
}
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "sha256:entry",
|
||||
To = "sha256:sink",
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.EnvGuard,
|
||||
Loc = "src/main.ts:10",
|
||||
Guard = "DEBUG=true",
|
||||
Confidence = 0.9,
|
||||
Metadata = new Dictionary<string, string> { ["source"] = "static-analysis" }.ToImmutableDictionary()
|
||||
}
|
||||
}
|
||||
],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Intoto = ["intoto:abc123"],
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = "sha256:sbom",
|
||||
Vex = "sha256:vex",
|
||||
Callgraph = "sha256:cg",
|
||||
RuntimeFacts = "sha256:rt",
|
||||
Policy = "sha256:policy"
|
||||
},
|
||||
ComputedAt = new DateTimeOffset(2025, 12, 27, 10, 0, 0, TimeSpan.Zero),
|
||||
Analyzer = new ReachGraphAnalyzer("stellaops", "1.0.0", "sha256:toolchain")
|
||||
},
|
||||
Signatures =
|
||||
[
|
||||
new ReachGraphSignature("key-1", "sig-base64-1"),
|
||||
new ReachGraphSignature("key-2", "sig-base64-2")
|
||||
]
|
||||
};
|
||||
|
||||
private static bool IsLexicographicallySorted<T>(ImmutableArray<T> items, Func<T, string> keySelector)
|
||||
{
|
||||
for (var i = 1; i < items.Length; i++)
|
||||
{
|
||||
if (string.Compare(keySelector(items[i - 1]), keySelector(items[i]), StringComparison.Ordinal) > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.Tests;
|
||||
|
||||
public class DigestComputerTests
|
||||
{
|
||||
private readonly CanonicalReachGraphSerializer _serializer = new();
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
|
||||
public DigestComputerTests()
|
||||
{
|
||||
_digestComputer = new ReachGraphDigestComputer(_serializer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_WithSameInput_ProducesSameDigest()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
|
||||
// Act
|
||||
var digest1 = _digestComputer.ComputeDigest(graph);
|
||||
var digest2 = _digestComputer.ComputeDigest(graph);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_ReturnsBlake3Format()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
|
||||
// Act
|
||||
var digest = _digestComputer.ComputeDigest(graph);
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("blake3:", digest);
|
||||
Assert.Equal(71, digest.Length); // "blake3:" (7) + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_ExcludesSignatures()
|
||||
{
|
||||
// Arrange
|
||||
var unsigned = CreateSampleGraph();
|
||||
var signed = unsigned with
|
||||
{
|
||||
Signatures = [new ReachGraphSignature("key-1", "sig-base64")]
|
||||
};
|
||||
|
||||
// Act
|
||||
var digestUnsigned = _digestComputer.ComputeDigest(unsigned);
|
||||
var digestSigned = _digestComputer.ComputeDigest(signed);
|
||||
|
||||
// Assert - signatures should not affect digest
|
||||
Assert.Equal(digestUnsigned, digestSigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentInputs_ProduceDifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = CreateSampleGraph();
|
||||
var graph2 = graph1 with
|
||||
{
|
||||
Artifact = new ReachGraphArtifact("different-app", "sha256:different", ["linux/amd64"])
|
||||
};
|
||||
|
||||
// Act
|
||||
var digest1 = _digestComputer.ComputeDigest(graph1);
|
||||
var digest2 = _digestComputer.ComputeDigest(graph2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDigest_ValidDigest_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var digest = _digestComputer.ComputeDigest(graph);
|
||||
|
||||
// Act
|
||||
var result = _digestComputer.VerifyDigest(graph, digest);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDigest_InvalidDigest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var wrongDigest = "blake3:0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
// Act
|
||||
var result = _digestComputer.VerifyDigest(graph, wrongDigest);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidBlake3Digest_ValidFormat_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var validDigest = "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
|
||||
// Act
|
||||
var result = ReachGraphDigestComputer.IsValidBlake3Digest(validDigest);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sha256:abcdef")] // Wrong algorithm
|
||||
[InlineData("blake3:short")] // Too short
|
||||
[InlineData("blake3:ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ")] // Invalid hex
|
||||
[InlineData("")] // Empty
|
||||
[InlineData("blake3")] // No colon
|
||||
public void IsValidBlake3Digest_InvalidFormat_ReturnsFalse(string digest)
|
||||
{
|
||||
// Act
|
||||
var result = ReachGraphDigestComputer.IsValidBlake3Digest(digest);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDigest_ValidFormat_ReturnsComponents()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "blake3:abc123def456";
|
||||
|
||||
// Act
|
||||
var result = ReachGraphDigestComputer.ParseDigest(digest);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("blake3", result.Value.Algorithm);
|
||||
Assert.Equal("abc123def456", result.Value.Hash);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("nocolon")]
|
||||
[InlineData(":noleft")]
|
||||
[InlineData("noright:")]
|
||||
public void ParseDigest_InvalidFormat_ReturnsNull(string digest)
|
||||
{
|
||||
// Act
|
||||
var result = ReachGraphDigestComputer.ParseDigest(digest);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_IsDeterministic_AcrossNodeOrdering()
|
||||
{
|
||||
// Arrange - nodes in different order
|
||||
var graph1 = CreateSampleGraph() with
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" },
|
||||
new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" }
|
||||
]
|
||||
};
|
||||
var graph2 = CreateSampleGraph() with
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" },
|
||||
new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var digest1 = _digestComputer.ComputeDigest(graph1);
|
||||
var digest2 = _digestComputer.ComputeDigest(graph2);
|
||||
|
||||
// Assert - canonical serialization should produce same digest regardless of input order
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
private static ReachGraphMinimal CreateSampleGraph() => new()
|
||||
{
|
||||
Artifact = new ReachGraphArtifact("test-app", "sha256:abc123", ["linux/amd64"]),
|
||||
Scope = new ReachGraphScope(["/app/main"], ["prod"]),
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode { Id = "sha256:001", Kind = ReachGraphNodeKind.Function, Ref = "main()" }
|
||||
],
|
||||
Edges = [],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Inputs = new ReachGraphInputs { Sbom = "sha256:sbom123" },
|
||||
ComputedAt = new DateTimeOffset(2025, 12, 27, 10, 0, 0, TimeSpan.Zero),
|
||||
Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain")
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.Tests;
|
||||
|
||||
public class EdgeExplanationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EdgeExplanationType.Import)]
|
||||
[InlineData(EdgeExplanationType.DynamicLoad)]
|
||||
[InlineData(EdgeExplanationType.Reflection)]
|
||||
[InlineData(EdgeExplanationType.Ffi)]
|
||||
[InlineData(EdgeExplanationType.EnvGuard)]
|
||||
[InlineData(EdgeExplanationType.FeatureFlag)]
|
||||
[InlineData(EdgeExplanationType.PlatformArch)]
|
||||
[InlineData(EdgeExplanationType.TaintGate)]
|
||||
[InlineData(EdgeExplanationType.LoaderRule)]
|
||||
[InlineData(EdgeExplanationType.DirectCall)]
|
||||
[InlineData(EdgeExplanationType.Unknown)]
|
||||
public void EdgeExplanationType_AllValues_AreValid(EdgeExplanationType type)
|
||||
{
|
||||
// Arrange
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = type,
|
||||
Confidence = 1.0
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(type, explanation.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeExplanation_WithAllProperties_CreatesValidRecord()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "static-analysis",
|
||||
["tool"] = "stellaops-scanner"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.EnvGuard,
|
||||
Loc = "src/main.ts:42",
|
||||
Guard = "NODE_ENV=production",
|
||||
Confidence = 0.95,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EdgeExplanationType.EnvGuard, explanation.Type);
|
||||
Assert.Equal("src/main.ts:42", explanation.Loc);
|
||||
Assert.Equal("NODE_ENV=production", explanation.Guard);
|
||||
Assert.Equal(0.95, explanation.Confidence);
|
||||
Assert.Equal(2, explanation.Metadata!.Count);
|
||||
Assert.Equal("static-analysis", explanation.Metadata["source"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeExplanation_MinimalProperties_CreatesValidRecord()
|
||||
{
|
||||
// Act
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.Import,
|
||||
Confidence = 1.0
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EdgeExplanationType.Import, explanation.Type);
|
||||
Assert.Null(explanation.Loc);
|
||||
Assert.Null(explanation.Guard);
|
||||
Assert.Equal(1.0, explanation.Confidence);
|
||||
Assert.Null(explanation.Metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
[InlineData(1.0)]
|
||||
public void EdgeExplanation_Confidence_AcceptsValidRange(double confidence)
|
||||
{
|
||||
// Act
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.DynamicLoad,
|
||||
Confidence = confidence
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(confidence, explanation.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachGraphEdge_WithExplanation_CreatesValidRecord()
|
||||
{
|
||||
// Arrange
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.FeatureFlag,
|
||||
Loc = "config.ts:10",
|
||||
Guard = "FEATURE_X=true",
|
||||
Confidence = 0.9
|
||||
};
|
||||
|
||||
// Act
|
||||
var edge = new ReachGraphEdge
|
||||
{
|
||||
From = "sha256:source",
|
||||
To = "sha256:target",
|
||||
Why = explanation
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("sha256:source", edge.From);
|
||||
Assert.Equal("sha256:target", edge.To);
|
||||
Assert.Equal(EdgeExplanationType.FeatureFlag, edge.Why.Type);
|
||||
Assert.Equal("FEATURE_X=true", edge.Why.Guard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeExplanationType_Enum_HasExpectedCount()
|
||||
{
|
||||
// Act
|
||||
var values = Enum.GetValues<EdgeExplanationType>();
|
||||
|
||||
// Assert - ensures we don't accidentally add/remove types without updating tests
|
||||
Assert.Equal(11, values.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeExplanationType_Unknown_IsDefault()
|
||||
{
|
||||
// Arrange
|
||||
EdgeExplanationType defaultValue = default;
|
||||
|
||||
// Assert - Unknown should be the first enum value (0)
|
||||
// Actually Import is 0, Unknown is last. Let's verify the enum structure
|
||||
Assert.Equal(EdgeExplanationType.Import, defaultValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeExplanation_GuardPatterns_ArePreserved()
|
||||
{
|
||||
// Test various guard pattern formats
|
||||
var guards = new[]
|
||||
{
|
||||
"DEBUG=true",
|
||||
"NODE_ENV=production",
|
||||
"FEATURE_X=truthy",
|
||||
"platform=linux",
|
||||
"os.name=Linux",
|
||||
"config.enableNewFeature=true"
|
||||
};
|
||||
|
||||
foreach (var guard in guards)
|
||||
{
|
||||
var explanation = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.EnvGuard,
|
||||
Guard = guard,
|
||||
Confidence = 0.9
|
||||
};
|
||||
|
||||
Assert.Equal(guard, explanation.Guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":"reachgraph.min@v1","artifact":{"name":"feature-app:v2.0.0","digest":"sha256:feature123def456789abc123def456789abc123def456789abc123def456789ab","env":["linux/amd64","linux/arm64"]},"scope":{"entrypoints":["/app/server"],"selectors":["prod","staging"],"cves":["CVE-2024-1234"]},"nodes":[{"id":"sha256:entry1","kind":"function","ref":"handleRequest()","file":"src/server.ts","line":10,"isEntrypoint":true},{"id":"sha256:check1","kind":"function","ref":"checkFeatureFlag()","file":"src/flags.ts","line":25},{"id":"sha256:vuln1","kind":"function","ref":"vulnerable.process()","file":"node_modules/vulnerable/index.js","line":100,"isSink":true}],"edges":[{"from":"sha256:entry1","to":"sha256:check1","why":{"type":"import","confidence":1.0,"loc":"src/server.ts:15"}},{"from":"sha256:check1","to":"sha256:vuln1","why":{"type":"featureFlag","confidence":0.9,"loc":"src/flags.ts:30","guard":"ENABLE_NEW_PROCESSOR=true"}}],"provenance":{"inputs":{"sbom":"sha256:sbom456def789abc123def456789abc123def456789abc123def456789abc123de","vex":"sha256:vex789abc123def456789abc123def456789abc123def456789abc123def456789"},"computedAt":"2025-12-27T11:00:00.000Z","analyzer":{"name":"stellaops-scanner","version":"1.0.0","toolchainDigest":"sha256:toolchain123456789"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":"reachgraph.min@v1","artifact":{"name":"example-app:v1.0.0","digest":"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abc1","env":["linux/amd64"]},"scope":{"entrypoints":["/app/bin/main"],"selectors":["prod"]},"nodes":[{"id":"sha256:0001","kind":"function","ref":"main()","file":"src/main.ts","line":1,"isEntrypoint":true},{"id":"sha256:0002","kind":"function","ref":"processData()","file":"src/processor.ts","line":42},{"id":"sha256:0003","kind":"function","ref":"lodash.template()","file":"node_modules/lodash/template.js","line":1,"isSink":true}],"edges":[{"from":"sha256:0001","to":"sha256:0002","why":{"type":"import","confidence":1.0,"loc":"src/main.ts:3"}},{"from":"sha256:0002","to":"sha256:0003","why":{"type":"import","confidence":1.0,"loc":"src/processor.ts:5"}}],"provenance":{"inputs":{"sbom":"sha256:sbom123abc456def789abc123def456789abc123def456789abc123def456789ab"},"computedAt":"2025-12-27T10:00:00.000Z","analyzer":{"name":"stellaops-scanner","version":"1.0.0","toolchainDigest":"sha256:toolchain123456789"}}}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Reflection;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.Tests;
|
||||
|
||||
public class GoldenSampleTests
|
||||
{
|
||||
private readonly CanonicalReachGraphSerializer _serializer = new();
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
|
||||
public GoldenSampleTests()
|
||||
{
|
||||
_digestComputer = new ReachGraphDigestComputer(_serializer);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GoldenSamples()
|
||||
{
|
||||
yield return ["Fixtures/simple-single-path.reachgraph.min.json"];
|
||||
yield return ["Fixtures/feature-flag-guards.reachgraph.min.json"];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSamples))]
|
||||
public void GoldenSample_Deserializes_Successfully(string resourcePath)
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource(resourcePath);
|
||||
|
||||
// Act
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal("reachgraph.min@v1", graph.SchemaVersion);
|
||||
Assert.NotEmpty(graph.Artifact.Name);
|
||||
Assert.NotEmpty(graph.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSamples))]
|
||||
public void GoldenSample_Roundtrip_ProducesSameDigest(string resourcePath)
|
||||
{
|
||||
// Arrange
|
||||
var originalJson = LoadEmbeddedResource(resourcePath);
|
||||
var graph = _serializer.Deserialize(originalJson);
|
||||
var originalDigest = _digestComputer.ComputeDigest(graph);
|
||||
|
||||
// Act - serialize and deserialize again
|
||||
var reserialized = _serializer.SerializeMinimal(graph);
|
||||
var reloaded = _serializer.Deserialize(reserialized);
|
||||
var reloadedDigest = _digestComputer.ComputeDigest(reloaded);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalDigest, reloadedDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimpleSinglePath_HasExpectedStructure()
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource("Fixtures/simple-single-path.reachgraph.min.json");
|
||||
|
||||
// Act
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("example-app:v1.0.0", graph.Artifact.Name);
|
||||
Assert.Contains("linux/amd64", graph.Artifact.Env);
|
||||
Assert.Equal(3, graph.Nodes.Length);
|
||||
Assert.Equal(2, graph.Edges.Length);
|
||||
|
||||
// Check nodes
|
||||
var entryNode = graph.Nodes.First(n => n.IsEntrypoint == true);
|
||||
Assert.Equal("main()", entryNode.Ref);
|
||||
|
||||
var sinkNode = graph.Nodes.First(n => n.IsSink == true);
|
||||
Assert.Equal("lodash.template()", sinkNode.Ref);
|
||||
|
||||
// Check edges
|
||||
Assert.All(graph.Edges, e => Assert.Equal(EdgeExplanationType.Import, e.Why.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagGuards_HasExpectedGuards()
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource("Fixtures/feature-flag-guards.reachgraph.min.json");
|
||||
|
||||
// Act
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("feature-app:v2.0.0", graph.Artifact.Name);
|
||||
Assert.NotNull(graph.Scope.Cves);
|
||||
Assert.Contains("CVE-2024-1234", graph.Scope.Cves.Value);
|
||||
|
||||
// Find the feature flag edge
|
||||
var featureFlagEdge = graph.Edges.FirstOrDefault(e => e.Why.Type == EdgeExplanationType.FeatureFlag);
|
||||
Assert.NotNull(featureFlagEdge);
|
||||
Assert.Equal("ENABLE_NEW_PROCESSOR=true", featureFlagEdge.Why.Guard);
|
||||
Assert.Equal(0.9, featureFlagEdge.Why.Confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSamples))]
|
||||
public void GoldenSample_PreservesProvenance(string resourcePath)
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource(resourcePath);
|
||||
|
||||
// Act
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Provenance);
|
||||
Assert.NotNull(graph.Provenance.Inputs);
|
||||
Assert.NotEmpty(graph.Provenance.Inputs.Sbom);
|
||||
Assert.NotEqual(default, graph.Provenance.ComputedAt);
|
||||
Assert.NotNull(graph.Provenance.Analyzer);
|
||||
Assert.NotEmpty(graph.Provenance.Analyzer.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSamples))]
|
||||
public void GoldenSample_NodeIds_AreValid(string resourcePath)
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource(resourcePath);
|
||||
var graph = _serializer.Deserialize(json);
|
||||
|
||||
// Act & Assert
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
Assert.NotEmpty(node.Id);
|
||||
Assert.NotEqual(ReachGraphNodeKind.Package, default); // Kind is set
|
||||
Assert.NotEmpty(node.Ref);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSamples))]
|
||||
public void GoldenSample_Edges_ReferenceValidNodes(string resourcePath)
|
||||
{
|
||||
// Arrange
|
||||
var json = LoadEmbeddedResource(resourcePath);
|
||||
var graph = _serializer.Deserialize(json);
|
||||
var nodeIds = graph.Nodes.Select(n => n.Id).ToHashSet();
|
||||
|
||||
// Act & Assert
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
Assert.NotEmpty(edge.From);
|
||||
Assert.NotEmpty(edge.To);
|
||||
// Note: We only check format, not that nodes exist (edges may reference external nodes)
|
||||
Assert.True(edge.Why.Confidence >= 0.0 && edge.Why.Confidence <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedResource(string resourcePath)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"StellaOps.ReachGraph.Tests.{resourcePath.Replace('/', '.')}";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.ReachGraph.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Fixtures\*.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Cryptography.Bcl;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
@@ -43,9 +43,10 @@ public class ReachabilityReplayWriterTests
|
||||
Assert.All(manifest.Reachability.RuntimeTraces, t => Assert.False(string.IsNullOrWhiteSpace(t.HashAlgorithm)));
|
||||
|
||||
// canonical hash should be stable regardless of input order
|
||||
var hash1 = manifest.ComputeCanonicalSha256(new BclCryptoHash());
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var hash1 = manifest.ComputeCanonicalSha256(cryptoHash);
|
||||
var manifest2 = ReachabilityReplayWriter.BuildManifestV2(scan, graphs.Reverse(), traces.Reverse(), analysisId: "analysis-123");
|
||||
var hash2 = manifest2.ComputeCanonicalSha256(new BclCryptoHash());
|
||||
var hash2 = manifest2.ComputeCanonicalSha256(cryptoHash);
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,8 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,19 +6,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Replay\StellaOps.Replay.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,579 +0,0 @@
|
||||
using System.Text;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FrameConverter"/>.
|
||||
/// </summary>
|
||||
public sealed class FrameConverterTests
|
||||
{
|
||||
#region ToFrame (RequestFrame) Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_ReturnsFrameWithRequestType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_SetsCorrelationIdFromRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame(correlationId: "test-correlation-123");
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("test-correlation-123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_UsesRequestIdWhenCorrelationIdIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RequestFrame
|
||||
{
|
||||
RequestId = "request-id-456",
|
||||
CorrelationId = null,
|
||||
Method = "GET",
|
||||
Path = "/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("request-id-456");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_SerializesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.Payload.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToRequestFrame Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_ValidRequestFrame_ReturnsRequestFrame()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame();
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_WrongFrameType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_InvalidJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Encoding.UTF8.GetBytes("invalid json {{{")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(requestId: "unique-request-id");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.RequestId.Should().Be("unique-request-id");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesMethod()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(method: "DELETE");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Method.Should().Be("DELETE");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesPath()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(path: "/api/users/123");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Path.Should().Be("/api/users/123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Custom-Header"] = "custom-value"
|
||||
};
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
Headers = headers
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers.Should().ContainKey("Content-Type");
|
||||
result.Headers["Content-Type"].Should().Be("application/json");
|
||||
result.Headers["X-Custom-Header"].Should().Be("custom-value");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"key\":\"value\"}");
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
Payload = payloadBytes
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesTimeoutSeconds()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
TimeoutSeconds = 60
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.TimeoutSeconds.Should().Be(60);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesSupportsStreaming()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
SupportsStreaming = true
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToFrame (ResponseFrame) Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_ResponseFrame_ReturnsFrameWithResponseType()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateTestResponseFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Response);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_ResponseFrame_SetsCorrelationIdToRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateTestResponseFrame(requestId: "req-123");
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("req-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToResponseFrame Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_ValidResponseFrame_ReturnsResponseFrame()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame();
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_WrongFrameType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_InvalidJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "test",
|
||||
Payload = Encoding.UTF8.GetBytes("not valid json")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame(requestId: "original-req-id");
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.RequestId.Should().Be("original-req-id");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame(statusCode: 404);
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.StatusCode.Should().Be(404);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["Cache-Control"] = "no-cache"
|
||||
};
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
Headers = headers
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers["Content-Type"].Should().Be("application/json");
|
||||
result.Headers["Cache-Control"].Should().Be("no-cache");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
Payload = payloadBytes
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesHasMoreChunks()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
HasMoreChunks = true
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.HasMoreChunks.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_EmptyPayload_ReturnsEmptyPayload()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.IsEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_NullHeaders_ReturnsEmptyHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test"
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers.Should().NotBeNull();
|
||||
result.Headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_EmptyPayload_ReturnsEmptyPayload()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 204,
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.IsEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToFrame_LargePayload_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var largePayload = new byte[1024 * 1024]; // 1MB
|
||||
Random.Shared.NextBytes(largePayload);
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/upload",
|
||||
Payload = largePayload
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(largePayload);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static RequestFrame CreateTestRequestFrame(
|
||||
string? requestId = null,
|
||||
string? correlationId = null,
|
||||
string method = "GET",
|
||||
string path = "/test")
|
||||
{
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = correlationId,
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private static ResponseFrame CreateTestResponseFrame(
|
||||
string? requestId = null,
|
||||
int statusCode = 200)
|
||||
{
|
||||
return new ResponseFrame
|
||||
{
|
||||
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
using System.Text;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive property-based tests for message framing integrity.
|
||||
/// Validates: message → frame → unframe → identical message.
|
||||
/// </summary>
|
||||
public sealed class MessageFramingRoundTripTests
|
||||
{
|
||||
#region Request Frame Complete Round-Trip Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_CompleteRoundTrip_AllFieldsPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "req-12345-67890",
|
||||
CorrelationId = "corr-abcdef-ghijkl",
|
||||
Method = "POST",
|
||||
Path = "/api/v2/users/create",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["Authorization"] = "Bearer token123",
|
||||
["X-Custom-Header"] = "custom-value",
|
||||
["Accept-Language"] = "en-US,en;q=0.9"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes(@"{""name"":""Test User"",""email"":""test@example.com""}"),
|
||||
TimeoutSeconds = 120,
|
||||
SupportsStreaming = true
|
||||
};
|
||||
|
||||
// Act - Frame and unframe
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert - All fields preserved
|
||||
restored.Should().NotBeNull();
|
||||
restored!.RequestId.Should().Be(original.RequestId);
|
||||
restored.CorrelationId.Should().Be(original.CorrelationId);
|
||||
restored.Method.Should().Be(original.Method);
|
||||
restored.Path.Should().Be(original.Path);
|
||||
restored.TimeoutSeconds.Should().Be(original.TimeoutSeconds);
|
||||
restored.SupportsStreaming.Should().Be(original.SupportsStreaming);
|
||||
restored.Headers.Should().BeEquivalentTo(original.Headers);
|
||||
restored.Payload.ToArray().Should().BeEquivalentTo(original.Payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("PATCH")]
|
||||
[InlineData("DELETE")]
|
||||
[InlineData("OPTIONS")]
|
||||
[InlineData("HEAD")]
|
||||
public void RequestFrame_AllHttpMethods_RoundTripCorrectly(string method)
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateMinimalRequestFrame(method: method);
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Method.Should().Be(method);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("/")]
|
||||
[InlineData("/api")]
|
||||
[InlineData("/api/users")]
|
||||
[InlineData("/api/users/123")]
|
||||
[InlineData("/api/users/123/orders/456")]
|
||||
[InlineData("/api/v1/organizations/{orgId}/teams/{teamId}/members")]
|
||||
[InlineData("/path/with spaces/encoded%20chars")]
|
||||
[InlineData("/unicode/日本語/パス")]
|
||||
public void RequestFrame_VariousPaths_RoundTripCorrectly(string path)
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateMinimalRequestFrame(path: path);
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Path.Should().Be(path);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_EmptyPayload_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Payload.Length.Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_LargePayload_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange - 1MB payload
|
||||
var largePayload = new byte[1024 * 1024];
|
||||
new Random(42).NextBytes(largePayload);
|
||||
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/api/upload",
|
||||
Payload = largePayload
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Payload.ToArray().Should().BeEquivalentTo(largePayload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_BinaryPayload_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange - Binary data with all byte values 0-255
|
||||
var binaryPayload = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();
|
||||
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/api/binary",
|
||||
Payload = binaryPayload
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Payload.ToArray().Should().BeEquivalentTo(binaryPayload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_NoHeaders_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_ManyHeaders_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange - 100 headers
|
||||
var headers = Enumerable.Range(0, 100)
|
||||
.ToDictionary(i => $"X-Header-{i:D3}", i => $"value-{i}");
|
||||
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
Headers = headers
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Headers.Should().BeEquivalentTo(headers);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(30)]
|
||||
[InlineData(60)]
|
||||
[InlineData(300)]
|
||||
[InlineData(3600)]
|
||||
public void RequestFrame_TimeoutValues_RoundTripCorrectly(int timeoutSeconds)
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
TimeoutSeconds = timeoutSeconds
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.TimeoutSeconds.Should().Be(timeoutSeconds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Frame Complete Round-Trip Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResponseFrame_CompleteRoundTrip_AllFieldsPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = "req-12345-67890",
|
||||
StatusCode = 201,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["Location"] = "/api/users/456",
|
||||
["X-Request-Id"] = "req-12345-67890"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes(@"{""id"":456,""status"":""created""}"),
|
||||
HasMoreChunks = false
|
||||
};
|
||||
|
||||
// Act - Frame and unframe
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert - All fields preserved
|
||||
restored.Should().NotBeNull();
|
||||
restored!.RequestId.Should().Be(original.RequestId);
|
||||
restored.StatusCode.Should().Be(original.StatusCode);
|
||||
restored.HasMoreChunks.Should().Be(original.HasMoreChunks);
|
||||
restored.Headers.Should().BeEquivalentTo(original.Headers);
|
||||
restored.Payload.ToArray().Should().BeEquivalentTo(original.Payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(200)]
|
||||
[InlineData(201)]
|
||||
[InlineData(204)]
|
||||
[InlineData(301)]
|
||||
[InlineData(302)]
|
||||
[InlineData(400)]
|
||||
[InlineData(401)]
|
||||
[InlineData(403)]
|
||||
[InlineData(404)]
|
||||
[InlineData(500)]
|
||||
[InlineData(502)]
|
||||
[InlineData(503)]
|
||||
public void ResponseFrame_AllStatusCodes_RoundTripCorrectly(int statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateMinimalResponseFrame(statusCode: statusCode);
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.StatusCode.Should().Be(statusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ResponseFrame_StreamingFlag_RoundTripsCorrectly(bool hasMoreChunks)
|
||||
{
|
||||
// Arrange
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
HasMoreChunks = hasMoreChunks
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.HasMoreChunks.Should().Be(hasMoreChunks);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Frame Type Discrimination Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_HasCorrectFrameType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateMinimalRequestFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResponseFrame_HasCorrectFrameType()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateMinimalResponseFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Response);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToRequestFrame_ReturnsNull_ForResponseFrame()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateMinimalResponseFrame();
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToResponseFrame_ReturnsNull_ForRequestFrame()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateMinimalRequestFrame();
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_MultipleRoundTrips_ProduceIdenticalResults()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "deterministic-test",
|
||||
CorrelationId = "corr-123",
|
||||
Method = "POST",
|
||||
Path = "/api/test",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes(@"{""key"":""value""}"),
|
||||
TimeoutSeconds = 60,
|
||||
SupportsStreaming = false
|
||||
};
|
||||
|
||||
// Act - Round-trip 100 times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ =>
|
||||
{
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
return FrameConverter.ToRequestFrame(frame);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical
|
||||
for (int i = 1; i < results.Count; i++)
|
||||
{
|
||||
results[i]!.RequestId.Should().Be(results[0]!.RequestId);
|
||||
results[i]!.CorrelationId.Should().Be(results[0]!.CorrelationId);
|
||||
results[i]!.Method.Should().Be(results[0]!.Method);
|
||||
results[i]!.Path.Should().Be(results[0]!.Path);
|
||||
results[i]!.TimeoutSeconds.Should().Be(results[0]!.TimeoutSeconds);
|
||||
results[i]!.SupportsStreaming.Should().Be(results[0]!.SupportsStreaming);
|
||||
results[i]!.Headers.Should().BeEquivalentTo(results[0]!.Headers);
|
||||
results[i]!.Payload.ToArray().Should().BeEquivalentTo(results[0]!.Payload.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResponseFrame_MultipleRoundTrips_ProduceIdenticalResults()
|
||||
{
|
||||
// Arrange
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = "deterministic-test",
|
||||
StatusCode = 200,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes(@"{""result"":""success""}"),
|
||||
HasMoreChunks = false
|
||||
};
|
||||
|
||||
// Act - Round-trip 100 times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ =>
|
||||
{
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
return FrameConverter.ToResponseFrame(frame);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical
|
||||
for (int i = 1; i < results.Count; i++)
|
||||
{
|
||||
results[i]!.RequestId.Should().Be(results[0]!.RequestId);
|
||||
results[i]!.StatusCode.Should().Be(results[0]!.StatusCode);
|
||||
results[i]!.HasMoreChunks.Should().Be(results[0]!.HasMoreChunks);
|
||||
results[i]!.Headers.Should().BeEquivalentTo(results[0]!.Headers);
|
||||
results[i]!.Payload.ToArray().Should().BeEquivalentTo(results[0]!.Payload.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Correlation ID Handling Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_CorrelationIdNull_UsesRequestIdInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "req-123",
|
||||
CorrelationId = null,
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("req-123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_CorrelationIdSet_UsesCorrelationIdInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "req-123",
|
||||
CorrelationId = "corr-456",
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("corr-456");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResponseFrame_UsesRequestIdAsCorrelationId()
|
||||
{
|
||||
// Arrange
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = "req-789",
|
||||
StatusCode = 200
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("req-789");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_SpecialCharactersInHeaders_RoundTripCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json; charset=utf-8",
|
||||
["Accept"] = "text/html, application/json, */*",
|
||||
["X-Unicode"] = "日本語ヘッダー値",
|
||||
["X-Special"] = "value with \"quotes\" and \\backslashes\\"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.Headers.Should().BeEquivalentTo(original.Headers);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_UnicodePayload_RoundTripsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var unicodeJson = @"{""name"":""日本語"",""emoji"":""🎉"",""special"":""™®©""}";
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/api/unicode",
|
||||
Payload = Encoding.UTF8.GetBytes(unicodeJson)
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
var restoredPayload = Encoding.UTF8.GetString(restored!.Payload.Span);
|
||||
restoredPayload.Should().Be(unicodeJson);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequestFrame_EmptyRequestId_RoundTripsCorrectly()
|
||||
{
|
||||
// Note: Empty RequestId is technically invalid but should still round-trip
|
||||
var original = new RequestFrame
|
||||
{
|
||||
RequestId = "",
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.RequestId.Should().Be("");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResponseFrame_ZeroStatusCode_RoundTripsCorrectly()
|
||||
{
|
||||
// Note: Zero status code is technically invalid but should still round-trip
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored!.StatusCode.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static RequestFrame CreateMinimalRequestFrame(
|
||||
string requestId = "test-id",
|
||||
string method = "GET",
|
||||
string path = "/api/test")
|
||||
{
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private static ResponseFrame CreateMinimalResponseFrame(
|
||||
string requestId = "test-id",
|
||||
int statusCode = 200)
|
||||
{
|
||||
return new ResponseFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PathMatcher"/>.
|
||||
/// </summary>
|
||||
public sealed class PathMatcherTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_SetsTemplate()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Assert
|
||||
matcher.Template.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_DefaultsCaseInsensitive()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/Users");
|
||||
|
||||
// Assert
|
||||
matcher.IsMatch("/api/users").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_CaseSensitive_DoesNotMatchDifferentCase()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/Users", caseInsensitive: false);
|
||||
|
||||
// Assert
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
matcher.IsMatch("/api/Users").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsMatch Tests - Exact Paths
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/health").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_TrailingSlash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/health/").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_NoLeadingSlash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("api/health").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_DifferentPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/status").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_PartialPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/list");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_LongerPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/users/list").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsMatch Tests - Case Sensitivity
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_CaseInsensitive_MatchesMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users", caseInsensitive: true);
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/API/USERS").Should().BeTrue();
|
||||
matcher.IsMatch("/Api/Users").Should().BeTrue();
|
||||
matcher.IsMatch("/aPi/uSeRs").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_CaseSensitive_OnlyMatchesExactCase()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/Api/Users", caseInsensitive: false);
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/Api/Users").Should().BeTrue();
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
matcher.IsMatch("/API/USERS").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Single Parameter
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsGuidParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}");
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
|
||||
// Act
|
||||
matcher.TryMatch($"/api/users/{guid}", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["userId"].Should().Be(guid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsStringParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{username}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/john-doe", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["username"].Should().Be("john-doe");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Multiple Parameters
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MultipleParameters_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/posts/456", out _);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MultipleParameters_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/user-1/posts/post-2", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("userId");
|
||||
parameters.Should().ContainKey("postId");
|
||||
parameters["userId"].Should().Be("user-1");
|
||||
parameters["postId"].Should().Be("post-2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ThreeParameters_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/org/{orgId}/users/{userId}/roles/{roleId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/org/acme/users/john/roles/admin", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().HaveCount(3);
|
||||
parameters["orgId"].Should().Be("acme");
|
||||
parameters["userId"].Should().Be("john");
|
||||
parameters["roleId"].Should().Be("admin");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Non-Matching
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/posts/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
parameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MissingParameter_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/posts", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ExtraSegment_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/extra", out _);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Path Normalization
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_TrailingSlash_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_NoLeadingSlash_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Parameter Type Constraints
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithTypeConstraint_ExtractsParameterName()
|
||||
{
|
||||
// Arrange
|
||||
// The PathMatcher ignores type constraints but still extracts the parameter
|
||||
var matcher = new PathMatcher("/api/users/{id:int}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithGuidConstraint_ExtractsParameterName()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id:guid}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/abc-123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("abc-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_RootPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SingleSegmentWithParameter_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/test-value", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("test-value");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_EmptyPath_HandlesGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/");
|
||||
|
||||
// Act
|
||||
var result = matcher.IsMatch("");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithHyphen_Extracts()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{user-id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("user-id");
|
||||
parameters["user-id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithUnderscore_Extracts()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{user_id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/456", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("user_id");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_SpecialCharactersInPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/search/{query}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/search/hello-world_test.123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["query"].Should().Be("hello-world_test.123");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsMatch_ComplexRealWorldPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}/vulnerabilities");
|
||||
|
||||
// Act
|
||||
var result = matcher.IsMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001/vulnerabilities");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ComplexRealWorldPath_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["orgId"].Should().Be("acme-corp");
|
||||
parameters["projectId"].Should().Be("webapp");
|
||||
parameters["scanId"].Should().Be("scan-2024-001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Property-based tests ensuring routing determinism: same message + same configuration = same route.
|
||||
/// </summary>
|
||||
public sealed class RoutingDeterminismTests
|
||||
{
|
||||
#region Core Determinism Property Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SameContextAndConnections_AlwaysSelectsSameRoute()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act - Run selection multiple times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DifferentConnectionOrder_ProducesSameResult()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections1 = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var connections2 = CreateConnectionSet(
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var result1 = selector.SelectConnection(context, connections1);
|
||||
var result2 = selector.SelectConnection(context, connections2);
|
||||
|
||||
// Assert - Should select same connection regardless of input order
|
||||
result1.ConnectionId.Should().Be(result2.ConnectionId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SamePathAndMethod_WithSameHeaders_ProducesSameRouteKey()
|
||||
{
|
||||
// Arrange
|
||||
var context1 = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/123",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-Correlation-Id"] = "corr-456",
|
||||
["Accept"] = "application/json"
|
||||
},
|
||||
GatewayRegion = "us-east"
|
||||
};
|
||||
|
||||
var context2 = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/123",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Accept"] = "application/json",
|
||||
["X-Correlation-Id"] = "corr-456"
|
||||
},
|
||||
GatewayRegion = "us-east"
|
||||
};
|
||||
|
||||
// Act
|
||||
var key1 = ComputeRouteKey(context1);
|
||||
var key2 = ComputeRouteKey(context2);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Region Affinity Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SameRegion_AlwaysPreferredWhenAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithRegion("us-east");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-remote", "instance-1", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-local", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select local region
|
||||
results.Should().AllSatisfy(r => r.Instance.Region.Should().Be("us-east"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NoLocalRegion_FallbackIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithRegion("ap-southeast");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "1.0.0", "us-west", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical (deterministic fallback)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Selection Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SameRequestedVersion_AlwaysSelectsMatchingConnection()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithVersion("2.0.0");
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v2", "instance-2", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v3", "instance-3", "service-a", "3.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select version 2.0.0
|
||||
results.Should().AllSatisfy(r => r.Instance.Version.Should().Be("2.0.0"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NoVersionRequested_LatestStableIsSelectedDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithVersion(null);
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "instance-1", "service-a", "1.2.3", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v2", "instance-2", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-v3", "instance-3", "service-a", "1.9.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical (should pick highest version deterministically)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Health Status Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HealthyConnectionsPreferred_Deterministically()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-unhealthy", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Unhealthy),
|
||||
("conn-healthy", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-degraded", "instance-3", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select healthy connection
|
||||
results.Should().AllSatisfy(r => r.ConnectionId.Should().Be("conn-healthy"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DegradedConnectionSelected_WhenNoHealthyAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-unhealthy", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Unhealthy),
|
||||
("conn-degraded-1", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded),
|
||||
("conn-degraded-2", "instance-3", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Degraded));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results identical (deterministic selection among degraded)
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
results[0].Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DrainingConnectionsExcluded()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-draining", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Draining),
|
||||
("conn-healthy", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var result = selector.SelectConnection(context, connections);
|
||||
|
||||
// Assert - Never select draining connections
|
||||
result.ConnectionId.Should().Be("conn-healthy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Criteria Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegionThenVersionThenHealth_OrderingIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var context = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/data",
|
||||
GatewayRegion = "us-east",
|
||||
RequestedVersion = "2.0.0",
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-1", "instance-1", "service-a", "2.0.0", "eu-west", InstanceHealthStatus.Healthy),
|
||||
("conn-2", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-3", "instance-3", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Degraded),
|
||||
("conn-4", "instance-4", "service-a", "2.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Should select conn-4: us-east region + version 2.0.0 + healthy
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Instance.Region.Should().Be("us-east");
|
||||
r.Instance.Version.Should().Be("2.0.0");
|
||||
r.Status.Should().Be(InstanceHealthStatus.Healthy);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreaker_UsesConnectionIdForConsistency()
|
||||
{
|
||||
// Arrange - Two identical connections except ID
|
||||
var context = CreateDeterministicContext();
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-zzz", "instance-1", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy),
|
||||
("conn-aaa", "instance-2", "service-a", "1.0.0", "us-east", InstanceHealthStatus.Healthy));
|
||||
|
||||
var selector = new DeterministicRouteSelector();
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.SelectConnection(context, connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always select alphabetically first connection ID for tie-breaking
|
||||
results.Should().AllSatisfy(r => r.ConnectionId.Should().Be("conn-aaa"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Matching Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathParameterMatching_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/orders/{orderId}");
|
||||
var testPaths = new[]
|
||||
{
|
||||
"/api/users/123/orders/456",
|
||||
"/api/users/abc/orders/xyz",
|
||||
"/api/users/user-1/orders/order-2"
|
||||
};
|
||||
|
||||
// Act & Assert - Each path should always produce same match result
|
||||
foreach (var path in testPaths)
|
||||
{
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => matcher.IsMatch(path))
|
||||
.ToList();
|
||||
|
||||
results.Should().AllBeEquivalentTo(results[0], $"Path {path} should match consistently");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MultipleEndpoints_SamePath_SelectsFirstMatchDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
CreateEndpoint("GET", "/api/users/{id}", "service-users", "1.0.0"),
|
||||
CreateEndpoint("GET", "/api/{resource}/{id}", "service-generic", "1.0.0")
|
||||
};
|
||||
|
||||
var selector = new EndpointMatcher(endpoints);
|
||||
var path = "/api/users/123";
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => selector.FindBestMatch("GET", path))
|
||||
.ToList();
|
||||
|
||||
// Assert - Always selects most specific match
|
||||
results.Should().AllSatisfy(r =>
|
||||
r.ServiceName.Should().Be("service-users"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static RoutingContext CreateDeterministicContext()
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-Request-Id"] = "deterministic-request-id"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContextWithRegion(string region)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = region,
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContextWithVersion(string? version)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east",
|
||||
RequestedVersion = version,
|
||||
Headers = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSet(
|
||||
params (string connId, string instId, string service, string version, string region, InstanceHealthStatus status)[] connections)
|
||||
{
|
||||
return connections.Select(c => new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.instId,
|
||||
ServiceName = c.service,
|
||||
Version = c.version,
|
||||
Region = c.region
|
||||
},
|
||||
Status = c.status,
|
||||
TransportType = TransportType.InMemory,
|
||||
ConnectedAtUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
LastHeartbeatUtc = new DateTime(2025, 1, 1, 0, 0, 1, DateTimeKind.Utc)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(string method, string path, string service, string version)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = service,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeRouteKey(RoutingContext context)
|
||||
{
|
||||
// Route key computation should be deterministic regardless of header order
|
||||
var sortedHeaders = context.Headers
|
||||
.OrderBy(h => h.Key, StringComparer.Ordinal)
|
||||
.Select(h => $"{h.Key}={h.Value}");
|
||||
|
||||
return $"{context.Method}|{context.Path}|{string.Join("&", sortedHeaders)}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Support Classes
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic route selector for testing.
|
||||
/// Implements the same algorithm that production code should use.
|
||||
/// </summary>
|
||||
private sealed class DeterministicRouteSelector
|
||||
{
|
||||
public ConnectionState SelectConnection(RoutingContext context, IReadOnlyList<ConnectionState> connections)
|
||||
{
|
||||
// Filter out draining and unhealthy connections
|
||||
var candidates = connections
|
||||
.Where(c => c.Status is InstanceHealthStatus.Healthy or InstanceHealthStatus.Degraded)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No available connections");
|
||||
}
|
||||
|
||||
// Apply version filter if requested
|
||||
if (!string.IsNullOrEmpty(context.RequestedVersion))
|
||||
{
|
||||
var versionMatches = candidates
|
||||
.Where(c => c.Instance.Version == context.RequestedVersion)
|
||||
.ToList();
|
||||
|
||||
if (versionMatches.Count > 0)
|
||||
{
|
||||
candidates = versionMatches;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer local region
|
||||
var localRegion = candidates
|
||||
.Where(c => c.Instance.Region == context.GatewayRegion)
|
||||
.ToList();
|
||||
|
||||
if (localRegion.Count > 0)
|
||||
{
|
||||
candidates = localRegion;
|
||||
}
|
||||
|
||||
// Prefer healthy over degraded
|
||||
var healthy = candidates
|
||||
.Where(c => c.Status == InstanceHealthStatus.Healthy)
|
||||
.ToList();
|
||||
|
||||
if (healthy.Count > 0)
|
||||
{
|
||||
candidates = healthy;
|
||||
}
|
||||
|
||||
// Deterministic tie-breaker: sort by connection ID
|
||||
return candidates
|
||||
.OrderBy(c => c.ConnectionId, StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint matcher for testing deterministic endpoint selection.
|
||||
/// </summary>
|
||||
private sealed class EndpointMatcher
|
||||
{
|
||||
private readonly IReadOnlyList<(PathMatcher Matcher, EndpointDescriptor Endpoint)> _endpoints;
|
||||
|
||||
public EndpointMatcher(IEnumerable<EndpointDescriptor> endpoints)
|
||||
{
|
||||
// Sort by specificity: more specific paths first (fewer parameters)
|
||||
_endpoints = endpoints
|
||||
.OrderBy(e => e.Path.Count(c => c == '{'))
|
||||
.ThenBy(e => e.Path, StringComparer.Ordinal)
|
||||
.Select(e => (new PathMatcher(e.Path), e))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public EndpointDescriptor FindBestMatch(string method, string path)
|
||||
{
|
||||
foreach (var (matcher, endpoint) in _endpoints)
|
||||
{
|
||||
if (endpoint.Method == method && matcher.IsMatch(path))
|
||||
{
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No endpoint found for {method} {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for routing rules evaluation: rule evaluation → correct destination.
|
||||
/// Tests path matching, endpoint selection, and routing criteria evaluation.
|
||||
/// </summary>
|
||||
public sealed class RoutingRulesEvaluationTests
|
||||
{
|
||||
#region Path Template Matching Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathMatcher_ExactPath_MatchesOnly()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/health").Should().BeTrue();
|
||||
matcher.IsMatch("/api/health/").Should().BeTrue();
|
||||
matcher.IsMatch("/api/healthz").Should().BeFalse();
|
||||
matcher.IsMatch("/api/health/check").Should().BeFalse();
|
||||
matcher.IsMatch("/api").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathMatcher_SingleParameter_CapturesValue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var matched = matcher.TryMatch("/api/users/12345", out var parameters);
|
||||
|
||||
// Assert
|
||||
matched.Should().BeTrue();
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("12345");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathMatcher_MultipleParameters_CapturesAllValues()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/orgs/{orgId}/teams/{teamId}/members/{memberId}");
|
||||
|
||||
// Act
|
||||
var matched = matcher.TryMatch("/api/orgs/org-1/teams/team-2/members/member-3", out var parameters);
|
||||
|
||||
// Assert
|
||||
matched.Should().BeTrue();
|
||||
parameters.Should().HaveCount(3);
|
||||
parameters["orgId"].Should().Be("org-1");
|
||||
parameters["teamId"].Should().Be("team-2");
|
||||
parameters["memberId"].Should().Be("member-3");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PathMatcher_SegmentMismatch_DoesNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}/profile");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/users/123/profile").Should().BeTrue();
|
||||
matcher.IsMatch("/api/users/123/settings").Should().BeFalse();
|
||||
matcher.IsMatch("/api/users/123").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("/api/users/123", true)]
|
||||
[InlineData("/api/users/abc-def-ghi", true)]
|
||||
[InlineData("/api/users/user@example.com", false)] // Contains @ which may be problematic
|
||||
[InlineData("/api/users/", false)] // Empty parameter
|
||||
[InlineData("/api/users", false)] // Missing parameter segment
|
||||
public void PathMatcher_ParameterVariations_HandlesCorrectly(string path, bool shouldMatch)
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var matched = matcher.IsMatch(path);
|
||||
|
||||
// Assert
|
||||
matched.Should().Be(shouldMatch);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Selection Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointSelection_MatchesByMethodAndPath()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = CreateEndpointSet(
|
||||
("GET", "/api/users", "user-service"),
|
||||
("POST", "/api/users", "user-service"),
|
||||
("GET", "/api/orders", "order-service"),
|
||||
("DELETE", "/api/users/{id}", "user-service"));
|
||||
|
||||
var selector = new TestEndpointSelector(endpoints);
|
||||
|
||||
// Act & Assert
|
||||
selector.FindEndpoint("GET", "/api/users")!.ServiceName.Should().Be("user-service");
|
||||
selector.FindEndpoint("POST", "/api/users")!.ServiceName.Should().Be("user-service");
|
||||
selector.FindEndpoint("GET", "/api/orders")!.ServiceName.Should().Be("order-service");
|
||||
selector.FindEndpoint("PUT", "/api/users").Should().BeNull(); // No PUT endpoint
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointSelection_MoreSpecificPathWins()
|
||||
{
|
||||
// Arrange - Specific path should win over parameterized path
|
||||
var endpoints = CreateEndpointSet(
|
||||
("GET", "/api/users/me", "user-self-service"),
|
||||
("GET", "/api/users/{id}", "user-service"));
|
||||
|
||||
var selector = new TestEndpointSelector(endpoints);
|
||||
|
||||
// Act
|
||||
var meEndpoint = selector.FindEndpoint("GET", "/api/users/me");
|
||||
var idEndpoint = selector.FindEndpoint("GET", "/api/users/123");
|
||||
|
||||
// Assert
|
||||
meEndpoint!.ServiceName.Should().Be("user-self-service");
|
||||
idEndpoint!.ServiceName.Should().Be("user-service");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointSelection_DifferentMethodsSamePath_SelectsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = CreateEndpointSet(
|
||||
("GET", "/api/items/{id}", "read-service"),
|
||||
("PUT", "/api/items/{id}", "write-service"),
|
||||
("DELETE", "/api/items/{id}", "delete-service"));
|
||||
|
||||
var selector = new TestEndpointSelector(endpoints);
|
||||
|
||||
// Act & Assert
|
||||
selector.FindEndpoint("GET", "/api/items/1")!.ServiceName.Should().Be("read-service");
|
||||
selector.FindEndpoint("PUT", "/api/items/1")!.ServiceName.Should().Be("write-service");
|
||||
selector.FindEndpoint("DELETE", "/api/items/1")!.ServiceName.Should().Be("delete-service");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Matching Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VersionMatching_ExactMatch_Required()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "service-a", "1.0.0"),
|
||||
("conn-v2", "service-a", "2.0.0"),
|
||||
("conn-v3", "service-a", "2.1.0"));
|
||||
|
||||
var filter = new VersionFilter(strictMatching: true);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections, requestedVersion: "2.0.0");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Instance.Version.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VersionMatching_NoVersionRequested_AllVersionsEligible()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "service-a", "1.0.0"),
|
||||
("conn-v2", "service-a", "2.0.0"));
|
||||
|
||||
var filter = new VersionFilter(strictMatching: false);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections, requestedVersion: null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VersionMatching_NoMatchingVersion_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSet(
|
||||
("conn-v1", "service-a", "1.0.0"),
|
||||
("conn-v2", "service-a", "1.1.0"));
|
||||
|
||||
var filter = new VersionFilter(strictMatching: true);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections, requestedVersion: "2.0.0");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Health Status Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HealthFilter_OnlyHealthy_WhenAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithHealth(
|
||||
("conn-healthy", InstanceHealthStatus.Healthy),
|
||||
("conn-degraded", InstanceHealthStatus.Degraded),
|
||||
("conn-unhealthy", InstanceHealthStatus.Unhealthy));
|
||||
|
||||
var filter = new HealthFilter(allowDegraded: true);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].ConnectionId.Should().Be("conn-healthy");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HealthFilter_DegradedFallback_WhenNoHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithHealth(
|
||||
("conn-degraded-1", InstanceHealthStatus.Degraded),
|
||||
("conn-degraded-2", InstanceHealthStatus.Degraded),
|
||||
("conn-unhealthy", InstanceHealthStatus.Unhealthy));
|
||||
|
||||
var filter = new HealthFilter(allowDegraded: true);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.All(c => c.Status == InstanceHealthStatus.Degraded).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HealthFilter_NoDegradedAllowed_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithHealth(
|
||||
("conn-degraded", InstanceHealthStatus.Degraded),
|
||||
("conn-unhealthy", InstanceHealthStatus.Unhealthy));
|
||||
|
||||
var filter = new HealthFilter(allowDegraded: false);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HealthFilter_DrainingAlwaysExcluded()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithHealth(
|
||||
("conn-draining", InstanceHealthStatus.Draining),
|
||||
("conn-healthy", InstanceHealthStatus.Healthy));
|
||||
|
||||
var filter = new HealthFilter(allowDegraded: true);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Status.Should().NotBe(InstanceHealthStatus.Draining);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Region Affinity Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegionFilter_LocalRegionFirst()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithRegion(
|
||||
("conn-remote", "us-west"),
|
||||
("conn-local", "eu-west"));
|
||||
|
||||
var filter = new RegionFilter(localRegion: "eu-west", neighbors: []);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Tier.Should().Be(0);
|
||||
result.Connections.Should().HaveCount(1);
|
||||
result.Connections[0].Instance.Region.Should().Be("eu-west");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegionFilter_NeighborTierSecond()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithRegion(
|
||||
("conn-far", "ap-southeast"),
|
||||
("conn-neighbor", "eu-central"));
|
||||
|
||||
var filter = new RegionFilter(localRegion: "eu-west", neighbors: ["eu-central", "eu-north"]);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Tier.Should().Be(1);
|
||||
result.Connections.Should().HaveCount(1);
|
||||
result.Connections[0].Instance.Region.Should().Be("eu-central");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegionFilter_GlobalTierLast()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithRegion(
|
||||
("conn-far-1", "ap-southeast"),
|
||||
("conn-far-2", "us-west"));
|
||||
|
||||
var filter = new RegionFilter(localRegion: "eu-west", neighbors: ["eu-central"]);
|
||||
|
||||
// Act
|
||||
var result = filter.Apply(connections);
|
||||
|
||||
// Assert
|
||||
result.Tier.Should().Be(2);
|
||||
result.Connections.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Latency-Based Rules
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LatencySort_LowestPingFirst()
|
||||
{
|
||||
// Arrange
|
||||
var connections = CreateConnectionSetWithLatency(
|
||||
("conn-high", 100.0),
|
||||
("conn-medium", 50.0),
|
||||
("conn-low", 10.0));
|
||||
|
||||
var sorter = new LatencySorter();
|
||||
|
||||
// Act
|
||||
var result = sorter.Sort(connections);
|
||||
|
||||
// Assert
|
||||
result[0].ConnectionId.Should().Be("conn-low");
|
||||
result[1].ConnectionId.Should().Be("conn-medium");
|
||||
result[2].ConnectionId.Should().Be("conn-high");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LatencySort_TiedPing_UsesHeartbeatRecency()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTime.UtcNow;
|
||||
var connections = new List<ConnectionState>
|
||||
{
|
||||
CreateConnectionWithLatencyAndHeartbeat("conn-old", 10.0, now.AddMinutes(-5)),
|
||||
CreateConnectionWithLatencyAndHeartbeat("conn-new", 10.0, now.AddMinutes(-1))
|
||||
};
|
||||
|
||||
var sorter = new LatencySorter();
|
||||
|
||||
// Act
|
||||
var result = sorter.Sort(connections);
|
||||
|
||||
// Assert - More recent heartbeat wins
|
||||
result[0].ConnectionId.Should().Be("conn-new");
|
||||
result[1].ConnectionId.Should().Be("conn-old");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rule Combination Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RuleChain_AppliesInOrder()
|
||||
{
|
||||
// Arrange - Multiple healthy connections, different regions, different pings
|
||||
var connections = new List<ConnectionState>
|
||||
{
|
||||
CreateFullConnection("remote-healthy-fast", "service", "1.0.0", "us-west", InstanceHealthStatus.Healthy, 5.0),
|
||||
CreateFullConnection("local-healthy-slow", "service", "1.0.0", "eu-west", InstanceHealthStatus.Healthy, 50.0),
|
||||
CreateFullConnection("local-degraded-fast", "service", "1.0.0", "eu-west", InstanceHealthStatus.Degraded, 1.0)
|
||||
};
|
||||
|
||||
var ruleChain = new RuleChain(
|
||||
localRegion: "eu-west",
|
||||
neighbors: [],
|
||||
allowDegraded: true,
|
||||
requestedVersion: "1.0.0");
|
||||
|
||||
// Act
|
||||
var result = ruleChain.Evaluate(connections);
|
||||
|
||||
// Assert - Should pick local healthy despite higher ping
|
||||
result.ConnectionId.Should().Be("local-healthy-slow");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RuleChain_FallsBackWhenNoIdealCandidate()
|
||||
{
|
||||
// Arrange - No local healthy connections
|
||||
var connections = new List<ConnectionState>
|
||||
{
|
||||
CreateFullConnection("remote-healthy", "service", "1.0.0", "us-west", InstanceHealthStatus.Healthy, 50.0),
|
||||
CreateFullConnection("local-degraded", "service", "1.0.0", "eu-west", InstanceHealthStatus.Degraded, 5.0)
|
||||
};
|
||||
|
||||
var ruleChain = new RuleChain(
|
||||
localRegion: "eu-west",
|
||||
neighbors: [],
|
||||
allowDegraded: true,
|
||||
requestedVersion: "1.0.0");
|
||||
|
||||
// Act
|
||||
var result = ruleChain.Evaluate(connections);
|
||||
|
||||
// Assert - Should pick local degraded over remote healthy (region preference)
|
||||
result.ConnectionId.Should().Be("local-degraded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Verification
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RuleEvaluation_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var connections = new List<ConnectionState>
|
||||
{
|
||||
CreateFullConnection("conn-1", "service", "1.0.0", "eu-west", InstanceHealthStatus.Healthy, 10.0),
|
||||
CreateFullConnection("conn-2", "service", "1.0.0", "eu-west", InstanceHealthStatus.Healthy, 10.0),
|
||||
CreateFullConnection("conn-3", "service", "1.0.0", "eu-west", InstanceHealthStatus.Healthy, 10.0)
|
||||
};
|
||||
|
||||
var ruleChain = new RuleChain(
|
||||
localRegion: "eu-west",
|
||||
neighbors: [],
|
||||
allowDegraded: true,
|
||||
requestedVersion: "1.0.0");
|
||||
|
||||
// Act - Evaluate multiple times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => ruleChain.Evaluate(connections))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical (deterministic tie-breaker)
|
||||
results.Should().AllSatisfy(r => r.ConnectionId.Should().Be(results[0].ConnectionId));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static List<EndpointDescriptor> CreateEndpointSet(
|
||||
params (string method, string path, string service)[] endpoints)
|
||||
{
|
||||
return endpoints.Select(e => new EndpointDescriptor
|
||||
{
|
||||
Method = e.method,
|
||||
Path = e.path,
|
||||
ServiceName = e.service,
|
||||
Version = "1.0.0"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSet(
|
||||
params (string connId, string service, string version)[] connections)
|
||||
{
|
||||
return connections.Select(c => new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.connId,
|
||||
ServiceName = c.service,
|
||||
Version = c.version,
|
||||
Region = "us-east"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSetWithHealth(
|
||||
params (string connId, InstanceHealthStatus status)[] connections)
|
||||
{
|
||||
return connections.Select(c => new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.connId,
|
||||
ServiceName = "service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east"
|
||||
},
|
||||
Status = c.status,
|
||||
TransportType = TransportType.InMemory
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSetWithRegion(
|
||||
params (string connId, string region)[] connections)
|
||||
{
|
||||
return connections.Select(c => new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.connId,
|
||||
ServiceName = "service",
|
||||
Version = "1.0.0",
|
||||
Region = c.region
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateConnectionSetWithLatency(
|
||||
params (string connId, double pingMs)[] connections)
|
||||
{
|
||||
return connections.Select(c =>
|
||||
{
|
||||
var conn = new ConnectionState
|
||||
{
|
||||
ConnectionId = c.connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = c.connId,
|
||||
ServiceName = "service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
conn.AveragePingMs = c.pingMs;
|
||||
return conn;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnectionWithLatencyAndHeartbeat(
|
||||
string connId, double pingMs, DateTime heartbeat)
|
||||
{
|
||||
var conn = new ConnectionState
|
||||
{
|
||||
ConnectionId = connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connId,
|
||||
ServiceName = "service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
conn.AveragePingMs = pingMs;
|
||||
conn.LastHeartbeatUtc = heartbeat;
|
||||
return conn;
|
||||
}
|
||||
|
||||
private static ConnectionState CreateFullConnection(
|
||||
string connId, string service, string version, string region,
|
||||
InstanceHealthStatus status, double pingMs)
|
||||
{
|
||||
var conn = new ConnectionState
|
||||
{
|
||||
ConnectionId = connId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connId,
|
||||
ServiceName = service,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
conn.AveragePingMs = pingMs;
|
||||
return conn;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Support Classes
|
||||
|
||||
private sealed class TestEndpointSelector
|
||||
{
|
||||
private readonly List<(PathMatcher Matcher, EndpointDescriptor Endpoint)> _endpoints;
|
||||
|
||||
public TestEndpointSelector(IEnumerable<EndpointDescriptor> endpoints)
|
||||
{
|
||||
// Sort by specificity: exact paths first, then parameterized
|
||||
_endpoints = endpoints
|
||||
.OrderBy(e => e.Path.Count(c => c == '{'))
|
||||
.ThenBy(e => e.Path, StringComparer.Ordinal)
|
||||
.Select(e => (new PathMatcher(e.Path), e))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public EndpointDescriptor? FindEndpoint(string method, string path)
|
||||
{
|
||||
foreach (var (matcher, endpoint) in _endpoints)
|
||||
{
|
||||
if (endpoint.Method == method && matcher.IsMatch(path))
|
||||
{
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VersionFilter
|
||||
{
|
||||
private readonly bool _strictMatching;
|
||||
|
||||
public VersionFilter(bool strictMatching) => _strictMatching = strictMatching;
|
||||
|
||||
public List<ConnectionState> Apply(List<ConnectionState> connections, string? requestedVersion)
|
||||
{
|
||||
if (string.IsNullOrEmpty(requestedVersion))
|
||||
{
|
||||
return connections;
|
||||
}
|
||||
|
||||
if (_strictMatching)
|
||||
{
|
||||
return connections
|
||||
.Where(c => c.Instance.Version == requestedVersion)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HealthFilter
|
||||
{
|
||||
private readonly bool _allowDegraded;
|
||||
|
||||
public HealthFilter(bool allowDegraded) => _allowDegraded = allowDegraded;
|
||||
|
||||
public List<ConnectionState> Apply(List<ConnectionState> connections)
|
||||
{
|
||||
var healthy = connections
|
||||
.Where(c => c.Status == InstanceHealthStatus.Healthy)
|
||||
.ToList();
|
||||
|
||||
if (healthy.Count > 0)
|
||||
{
|
||||
return healthy;
|
||||
}
|
||||
|
||||
if (_allowDegraded)
|
||||
{
|
||||
return connections
|
||||
.Where(c => c.Status == InstanceHealthStatus.Degraded)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegionFilter
|
||||
{
|
||||
private readonly string _localRegion;
|
||||
private readonly List<string> _neighbors;
|
||||
|
||||
public RegionFilter(string localRegion, IEnumerable<string> neighbors)
|
||||
{
|
||||
_localRegion = localRegion;
|
||||
_neighbors = neighbors.ToList();
|
||||
}
|
||||
|
||||
public (int Tier, List<ConnectionState> Connections) Apply(List<ConnectionState> connections)
|
||||
{
|
||||
var local = connections
|
||||
.Where(c => string.Equals(c.Instance.Region, _localRegion, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (local.Count > 0)
|
||||
{
|
||||
return (0, local);
|
||||
}
|
||||
|
||||
var neighbor = connections
|
||||
.Where(c => _neighbors.Contains(c.Instance.Region, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (neighbor.Count > 0)
|
||||
{
|
||||
return (1, neighbor);
|
||||
}
|
||||
|
||||
return (2, connections);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LatencySorter
|
||||
{
|
||||
public List<ConnectionState> Sort(List<ConnectionState> connections)
|
||||
{
|
||||
return connections
|
||||
.OrderBy(c => c.AveragePingMs)
|
||||
.ThenByDescending(c => c.LastHeartbeatUtc)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RuleChain
|
||||
{
|
||||
private readonly string _localRegion;
|
||||
private readonly List<string> _neighbors;
|
||||
private readonly bool _allowDegraded;
|
||||
private readonly string? _requestedVersion;
|
||||
|
||||
public RuleChain(string localRegion, IEnumerable<string> neighbors, bool allowDegraded, string? requestedVersion)
|
||||
{
|
||||
_localRegion = localRegion;
|
||||
_neighbors = neighbors.ToList();
|
||||
_allowDegraded = allowDegraded;
|
||||
_requestedVersion = requestedVersion;
|
||||
}
|
||||
|
||||
public ConnectionState Evaluate(List<ConnectionState> connections)
|
||||
{
|
||||
// Step 1: Version filter
|
||||
var versionFilter = new VersionFilter(strictMatching: true);
|
||||
var afterVersion = versionFilter.Apply(connections, _requestedVersion);
|
||||
|
||||
// Step 2: Health filter
|
||||
var healthFilter = new HealthFilter(_allowDegraded);
|
||||
var afterHealth = healthFilter.Apply(afterVersion);
|
||||
|
||||
// Step 3: Region filter
|
||||
var regionFilter = new RegionFilter(_localRegion, _neighbors);
|
||||
var (_, afterRegion) = regionFilter.Apply(afterHealth);
|
||||
|
||||
// Step 4: Latency sort
|
||||
var latencySorter = new LatencySorter();
|
||||
var sorted = latencySorter.Sort(afterRegion);
|
||||
|
||||
// Step 5: Deterministic tie-breaker by ConnectionId
|
||||
return sorted
|
||||
.OrderBy(c => c.ConnectionId, StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Common.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,93 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfigChangedEventArgs"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigChangedEventArgsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_SetsPreviousConfig()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Previous.Should().BeSameAs(previous);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_SetsCurrentConfig()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Current.Should().BeSameAs(current);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_SetsChangedAtToCurrentTime()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
var beforeCreate = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
var afterCreate = DateTime.UtcNow;
|
||||
|
||||
// Assert
|
||||
args.ChangedAt.Should().BeOnOrAfter(beforeCreate);
|
||||
args.ChangedAt.Should().BeOnOrBefore(afterCreate);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_DifferentConfigs_BothAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig
|
||||
{
|
||||
Routing = new RoutingOptions { LocalRegion = "us-west-1" }
|
||||
};
|
||||
var current = new RouterConfig
|
||||
{
|
||||
Routing = new RoutingOptions { LocalRegion = "us-east-1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Previous.Routing.LocalRegion.Should().Be("us-west-1");
|
||||
args.Current.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConfigChangedEventArgs_InheritsFromEventArgs()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Should().BeAssignableTo<EventArgs>();
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfigValidationResult"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigValidationResultTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Errors_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().NotBeNull();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Warnings_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().NotBeNull();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsValid Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsValid_NoErrors_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsValid_WithErrors_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Some error");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsValid_WithOnlyWarnings_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Warnings.Add("Some warning");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsValid_WithErrorsAndWarnings_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Some error");
|
||||
result.Warnings.Add("Some warning");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IsValid_MultipleErrors_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Error 1");
|
||||
result.Errors.Add("Error 2");
|
||||
result.Errors.Add("Error 3");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Success Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Success_ReturnsValidResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ConfigValidationResult.Success;
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Success_ReturnsNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result1 = ConfigValidationResult.Success;
|
||||
var result2 = ConfigValidationResult.Success;
|
||||
|
||||
// Assert - Should be different instances to allow mutation without affecting shared state
|
||||
result1.Should().NotBeSameAs(result2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Errors Collection Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Errors_CanBeModified()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act
|
||||
result.Errors.Add("Error 1");
|
||||
result.Errors.Add("Error 2");
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().HaveCount(2);
|
||||
result.Errors.Should().Contain("Error 1");
|
||||
result.Errors.Should().Contain("Error 2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Errors_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult
|
||||
{
|
||||
Errors = ["Error 1", "Error 2"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().HaveCount(2);
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warnings Collection Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Warnings_CanBeModified()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act
|
||||
result.Warnings.Add("Warning 1");
|
||||
result.Warnings.Add("Warning 2");
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().HaveCount(2);
|
||||
result.Warnings.Should().Contain("Warning 1");
|
||||
result.Warnings.Should().Contain("Warning 2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Warnings_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult
|
||||
{
|
||||
Warnings = ["Warning 1", "Warning 2"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().HaveCount(2);
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't affect validity
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfigOptions"/>.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_ConfigPath_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ConfigPath.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_EnvironmentVariablePrefix_DefaultsToStellaOpsRouter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.EnvironmentVariablePrefix.Should().Be("STELLAOPS_ROUTER_");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_EnableHotReload_DefaultsToTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.EnableHotReload.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_DebounceInterval_DefaultsTo500Milliseconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.DebounceInterval.Should().Be(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_ThrowOnValidationError_DefaultsToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ThrowOnValidationError.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_ConfigurationSection_DefaultsToRouter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ConfigurationSection.Should().Be("Router");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConfigPath_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ConfigPath = "/etc/stellaops/router.yaml";
|
||||
|
||||
// Assert
|
||||
options.ConfigPath.Should().Be("/etc/stellaops/router.yaml");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvironmentVariablePrefix_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.EnvironmentVariablePrefix = "CUSTOM_PREFIX_";
|
||||
|
||||
// Assert
|
||||
options.EnvironmentVariablePrefix.Should().Be("CUSTOM_PREFIX_");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnableHotReload_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.EnableHotReload = false;
|
||||
|
||||
// Assert
|
||||
options.EnableHotReload.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DebounceInterval_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.DebounceInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Assert
|
||||
options.DebounceInterval.Should().Be(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ThrowOnValidationError_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ThrowOnValidationError = true;
|
||||
|
||||
// Assert
|
||||
options.ThrowOnValidationError.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConfigurationSection_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ConfigurationSection = "CustomSection";
|
||||
|
||||
// Assert
|
||||
options.ConfigurationSection.Should().Be("CustomSection");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfigProvider"/> and configuration validation.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigProviderTests : IDisposable
|
||||
{
|
||||
private readonly ILogger<RouterConfigProvider> _logger;
|
||||
private RouterConfigProvider? _provider;
|
||||
|
||||
public RouterConfigProviderTests()
|
||||
{
|
||||
_logger = NullLogger<RouterConfigProvider>.Instance;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider?.Dispose();
|
||||
}
|
||||
|
||||
private RouterConfigProvider CreateProvider(RouterConfigOptions? options = null)
|
||||
{
|
||||
var opts = Options.Create(options ?? new RouterConfigOptions { EnableHotReload = false });
|
||||
_provider = new RouterConfigProvider(opts, _logger);
|
||||
return _provider;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_InitializesCurrentConfig()
|
||||
{
|
||||
// Arrange & Act
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Assert
|
||||
provider.Current.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_ExposesOptions()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions
|
||||
{
|
||||
ConfigPath = "/test/path.yaml",
|
||||
EnableHotReload = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = CreateProvider(options);
|
||||
|
||||
// Assert
|
||||
provider.Options.Should().NotBeNull();
|
||||
provider.Options.ConfigPath.Should().Be("/test/path.yaml");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_WithHotReloadDisabled_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions { EnableHotReload = false };
|
||||
|
||||
// Act
|
||||
var action = () => CreateProvider(options);
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - PayloadLimits
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ValidConfig_ReturnsIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxRequestBytesPerCall_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NegativeMaxRequestBytesPerCall_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = -1 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxRequestBytesPerConnection_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerConnection = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerConnection"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxAggregateInflightBytes_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxAggregateInflightBytes = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxAggregateInflightBytes"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_MaxCallBytesLargerThanConnectionBytes_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 100 * 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("MaxRequestBytesPerCall") && w.Contains("MaxRequestBytesPerConnection"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - RoutingOptions
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ZeroDefaultTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Routing.DefaultTimeout = TimeSpan.Zero;
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NegativeDefaultTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Routing.DefaultTimeout = TimeSpan.FromSeconds(-1);
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - Services
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EmptyServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WhitespaceServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = " " });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_DuplicateServiceNames_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_DuplicateServiceNamesCaseInsensitive_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "MyService" });
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "myservice" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EndpointEmptyMethod_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "", Path = "/test" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("endpoint method cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EndpointEmptyPath_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "GET", Path = "" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("endpoint path cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EndpointNonPositiveTimeout_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "GET", Path = "/test", DefaultTimeout = TimeSpan.Zero }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("non-positive timeout"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - StaticInstances
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_StaticInstanceEmptyServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Static instance service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_StaticInstanceEmptyHost_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "",
|
||||
Port = 8080
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("host cannot be empty"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(65536)]
|
||||
[InlineData(70000)]
|
||||
public void Validate_StaticInstanceInvalidPort_ReturnsError(int port)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = port
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("port must be between 1 and 65535"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(80)]
|
||||
[InlineData(443)]
|
||||
[InlineData(8080)]
|
||||
[InlineData(65535)]
|
||||
public void Validate_StaticInstanceValidPort_Succeeds(int port)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = port
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Validate_StaticInstanceNonPositiveWeight_ReturnsWarning(int weight)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080,
|
||||
Weight = weight
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("weight should be positive"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReloadAsync Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReloadAsync_ValidConfig_UpdatesCurrentConfig()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.ReloadAsync();
|
||||
|
||||
// Assert - Config should be reloaded (same content in this case since no file)
|
||||
provider.Current.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReloadAsync_InvalidConfig_ThrowsConfigurationException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
// Set invalid payload limits - ReloadAsync should validate the config from file/defaults,
|
||||
// but since there's no file, it reloads successfully with defaults.
|
||||
// This test validates that if an invalid config were loaded, validation would fail.
|
||||
// For now, we test that ReloadAsync completes without error when no config file exists.
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
|
||||
|
||||
// Act - ReloadAsync uses defaults when no file exists, so no exception is thrown
|
||||
await provider.ReloadAsync();
|
||||
|
||||
// Assert - Config is reloaded with valid defaults
|
||||
provider.Current.PayloadLimits.MaxRequestBytesPerCall.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReloadAsync_Cancelled_ThrowsOperationCanceledException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert - TaskCanceledException inherits from OperationCanceledException
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => provider.ReloadAsync(cts.Token));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ConfigurationChanged Event Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReloadAsync_RaisesConfigurationChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
ConfigChangedEventArgs? eventArgs = null;
|
||||
provider.ConfigurationChanged += (_, args) => eventArgs = args;
|
||||
|
||||
// Act
|
||||
await provider.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
eventArgs.Should().NotBeNull();
|
||||
eventArgs!.Previous.Should().NotBeNull();
|
||||
eventArgs.Current.Should().NotBeNull();
|
||||
eventArgs.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
provider.Dispose();
|
||||
provider.Dispose();
|
||||
provider.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_PayloadLimits_DefaultsToNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Routing_DefaultsToNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Routing.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Services_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Services.Should().NotBeNull();
|
||||
config.Services.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_StaticInstances_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().NotBeNull();
|
||||
config.StaticInstances.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PayloadLimits Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PayloadLimits_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(10 * 1024 * 1024); // 10 MB
|
||||
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(100 * 1024 * 1024); // 100 MB
|
||||
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(1024 * 1024 * 1024); // 1 GB
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PayloadLimits_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 5 * 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 50 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 500 * 1024 * 1024
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(5 * 1024 * 1024);
|
||||
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(50 * 1024 * 1024);
|
||||
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(500 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Routing_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Routing.LocalRegion.Should().Be("default");
|
||||
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
|
||||
config.Routing.PreferLocalRegion.Should().BeTrue();
|
||||
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Routing_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.Routing = new RoutingOptions
|
||||
{
|
||||
LocalRegion = "us-east-1",
|
||||
TieBreaker = TieBreakerStrategy.LeastLoaded,
|
||||
PreferLocalRegion = false,
|
||||
DefaultTimeout = TimeSpan.FromMinutes(2),
|
||||
NeighborRegions = ["us-west-1", "eu-west-1"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.LeastLoaded);
|
||||
config.Routing.PreferLocalRegion.Should().BeFalse();
|
||||
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(2));
|
||||
config.Routing.NeighborRegions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Services Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Services_CanAddServices()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.Services.Add(new ServiceConfig { ServiceName = "service-a" });
|
||||
config.Services.Add(new ServiceConfig { ServiceName = "service-b" });
|
||||
|
||||
// Assert
|
||||
config.Services.Should().HaveCount(2);
|
||||
config.Services[0].ServiceName.Should().Be("service-a");
|
||||
config.Services[1].ServiceName.Should().Be("service-b");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Services_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
Services =
|
||||
[
|
||||
new ServiceConfig { ServiceName = "auth" },
|
||||
new ServiceConfig { ServiceName = "users" },
|
||||
new ServiceConfig { ServiceName = "orders" }
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Services.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StaticInstances Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StaticInstances_CanAddInstances()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "legacy-service",
|
||||
Version = "1.0",
|
||||
Host = "legacy.internal",
|
||||
Port = 9000
|
||||
});
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().HaveCount(1);
|
||||
config.StaticInstances[0].ServiceName.Should().Be("legacy-service");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StaticInstances_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
StaticInstances =
|
||||
[
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "db-proxy",
|
||||
Version = "2.0",
|
||||
Host = "db-proxy-1.internal",
|
||||
Port = 5432
|
||||
},
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "db-proxy",
|
||||
Version = "2.0",
|
||||
Host = "db-proxy-2.internal",
|
||||
Port = 5432
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Configuration Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 100 * 1024 * 1024
|
||||
},
|
||||
Routing = new RoutingOptions
|
||||
{
|
||||
LocalRegion = "us-east-1",
|
||||
NeighborRegions = ["us-west-1"],
|
||||
TieBreaker = TieBreakerStrategy.ConsistentHash,
|
||||
PreferLocalRegion = true,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60)
|
||||
},
|
||||
Services =
|
||||
[
|
||||
new ServiceConfig
|
||||
{
|
||||
ServiceName = "api-gateway",
|
||||
DefaultVersion = "1.0.0",
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointConfig { Method = "GET", Path = "/health" }
|
||||
]
|
||||
}
|
||||
],
|
||||
StaticInstances =
|
||||
[
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api-gateway",
|
||||
Version = "1.0.0",
|
||||
Host = "api-1.internal",
|
||||
Port = 8080,
|
||||
Weight = 100
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(1024 * 1024);
|
||||
config.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
config.Services.Should().HaveCount(1);
|
||||
config.StaticInstances.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RoutingOptions"/> and <see cref="TieBreakerStrategy"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutingOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_LocalRegion_DefaultsToDefault()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.LocalRegion.Should().Be("default");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NeighborRegions_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.NeighborRegions.Should().NotBeNull();
|
||||
options.NeighborRegions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_TieBreaker_DefaultsToRoundRobin()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_PreferLocalRegion_DefaultsToTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.PreferLocalRegion.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_DefaultTimeout_DefaultsTo30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LocalRegion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.LocalRegion = "us-east-1";
|
||||
|
||||
// Assert
|
||||
options.LocalRegion.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NeighborRegions_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.NeighborRegions = ["us-west-1", "eu-west-1"];
|
||||
|
||||
// Assert
|
||||
options.NeighborRegions.Should().HaveCount(2);
|
||||
options.NeighborRegions.Should().Contain("us-west-1");
|
||||
options.NeighborRegions.Should().Contain("eu-west-1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(TieBreakerStrategy.RoundRobin)]
|
||||
[InlineData(TieBreakerStrategy.Random)]
|
||||
[InlineData(TieBreakerStrategy.LeastLoaded)]
|
||||
[InlineData(TieBreakerStrategy.ConsistentHash)]
|
||||
public void TieBreaker_CanBeSetToAllStrategies(TieBreakerStrategy strategy)
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.TieBreaker = strategy;
|
||||
|
||||
// Assert
|
||||
options.TieBreaker.Should().Be(strategy);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PreferLocalRegion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.PreferLocalRegion = false;
|
||||
|
||||
// Assert
|
||||
options.PreferLocalRegion.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.DefaultTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TieBreakerStrategy Enum Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_HasFourValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var values = Enum.GetValues<TieBreakerStrategy>();
|
||||
|
||||
// Assert
|
||||
values.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_RoundRobin_HasValueZero()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.RoundRobin).Should().Be(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_Random_HasValueOne()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.Random).Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_LeastLoaded_HasValueTwo()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.LeastLoaded).Should().Be(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_ConsistentHash_HasValueThree()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.ConsistentHash).Should().Be(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServiceConfig"/> and <see cref="EndpointConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class ServiceConfigTests
|
||||
{
|
||||
#region ServiceConfig Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultVersion_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.DefaultVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultTransport_DefaultsToTcp()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.DefaultTransport.Should().Be(TransportType.Tcp);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_Endpoints_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.Endpoints.Should().NotBeNull();
|
||||
config.Endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ServiceConfig Property Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_ServiceName_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "my-service" };
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("my-service");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultVersion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.DefaultVersion = "1.0.0";
|
||||
|
||||
// Assert
|
||||
config.DefaultVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(TransportType.Tcp)]
|
||||
[InlineData(TransportType.Certificate)]
|
||||
[InlineData(TransportType.Udp)]
|
||||
[InlineData(TransportType.InMemory)]
|
||||
[InlineData(TransportType.RabbitMq)]
|
||||
public void ServiceConfig_DefaultTransport_CanBeSetToAllTypes(TransportType transport)
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.DefaultTransport = transport;
|
||||
|
||||
// Assert
|
||||
config.DefaultTransport.Should().Be(transport);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_Endpoints_CanAddEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.Endpoints.Add(new EndpointConfig { Method = "GET", Path = "/api/health" });
|
||||
config.Endpoints.Add(new EndpointConfig { Method = "POST", Path = "/api/data" });
|
||||
|
||||
// Assert
|
||||
config.Endpoints.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EndpointConfig Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_DefaultTimeout_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.DefaultTimeout.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_SupportsStreaming_DefaultsToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.SupportsStreaming.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_RequiringClaims_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.RequiringClaims.Should().NotBeNull();
|
||||
endpoint.RequiringClaims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EndpointConfig Property Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_Method_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "DELETE", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.Method.Should().Be("DELETE");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_Path_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/api/users/{id}" };
|
||||
|
||||
// Assert
|
||||
endpoint.Path.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_DefaultTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.DefaultTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Assert
|
||||
endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_SupportsStreaming_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.SupportsStreaming = true;
|
||||
|
||||
// Assert
|
||||
endpoint.SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointConfig_RequiringClaims_CanAddClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "role", Value = "admin" });
|
||||
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "permission", Value = "read" });
|
||||
|
||||
// Assert
|
||||
endpoint.RequiringClaims.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Configuration Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceConfig_CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig
|
||||
{
|
||||
ServiceName = "user-service",
|
||||
DefaultVersion = "2.0.0",
|
||||
DefaultTransport = TransportType.Certificate,
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/{id}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10),
|
||||
SupportsStreaming = false,
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
|
||||
},
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
SupportsStreaming = false,
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
},
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/stream",
|
||||
DefaultTimeout = TimeSpan.FromMinutes(5),
|
||||
SupportsStreaming = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("user-service");
|
||||
config.DefaultVersion.Should().Be("2.0.0");
|
||||
config.DefaultTransport.Should().Be(TransportType.Certificate);
|
||||
config.Endpoints.Should().HaveCount(3);
|
||||
config.Endpoints[0].RequiringClaims.Should().HaveCount(1);
|
||||
config.Endpoints[2].SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StaticInstanceConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class StaticInstanceConfigTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Region_DefaultsToDefault()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Region.Should().Be("default");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Transport_DefaultsToTcp()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Transport.Should().Be(TransportType.Tcp);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Weight_DefaultsTo100()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Weight.Should().Be(100);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_Metadata_DefaultsToEmptyDictionary()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Metadata.Should().NotBeNull();
|
||||
config.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Required Properties Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceName_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "required-service",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("required-service");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Version_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "2.3.4",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Version.Should().Be("2.3.4");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Host_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "192.168.1.100",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Host.Should().Be("192.168.1.100");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Port_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 443
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Port.Should().Be(443);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Region_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Region = "us-west-2";
|
||||
|
||||
// Assert
|
||||
config.Region.Should().Be("us-west-2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(TransportType.Tcp)]
|
||||
[InlineData(TransportType.Certificate)]
|
||||
[InlineData(TransportType.Udp)]
|
||||
[InlineData(TransportType.InMemory)]
|
||||
[InlineData(TransportType.RabbitMq)]
|
||||
public void Transport_CanBeSetToAllTypes(TransportType transport)
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Transport = transport;
|
||||
|
||||
// Assert
|
||||
config.Transport.Should().Be(transport);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(50)]
|
||||
[InlineData(100)]
|
||||
[InlineData(200)]
|
||||
[InlineData(1000)]
|
||||
public void Weight_CanBeSet(int weight)
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Weight = weight;
|
||||
|
||||
// Assert
|
||||
config.Weight.Should().Be(weight);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Metadata_CanAddEntries()
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Metadata["environment"] = "production";
|
||||
config.Metadata["cluster"] = "primary";
|
||||
|
||||
// Assert
|
||||
config.Metadata.Should().HaveCount(2);
|
||||
config.Metadata["environment"].Should().Be("production");
|
||||
config.Metadata["cluster"].Should().Be("primary");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Configuration Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "user-service",
|
||||
Version = "3.2.1",
|
||||
Region = "eu-central-1",
|
||||
Host = "user-svc.internal.example.com",
|
||||
Port = 8443,
|
||||
Transport = TransportType.Certificate,
|
||||
Weight = 150,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["datacenter"] = "dc1",
|
||||
["rack"] = "rack-42",
|
||||
["shard"] = "primary"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("user-service");
|
||||
config.Version.Should().Be("3.2.1");
|
||||
config.Region.Should().Be("eu-central-1");
|
||||
config.Host.Should().Be("user-svc.internal.example.com");
|
||||
config.Port.Should().Be(8443);
|
||||
config.Transport.Should().Be(TransportType.Certificate);
|
||||
config.Weight.Should().Be(150);
|
||||
config.Metadata.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MultipleInstances_CanHaveDifferentWeights()
|
||||
{
|
||||
// Arrange & Act
|
||||
var primary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "primary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 200
|
||||
};
|
||||
|
||||
var secondary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "secondary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 100
|
||||
};
|
||||
|
||||
var tertiary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "tertiary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 50
|
||||
};
|
||||
|
||||
// Assert
|
||||
primary.Weight.Should().BeGreaterThan(secondary.Weight);
|
||||
secondary.Weight.Should().BeGreaterThan(tertiary.Weight);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Config.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,211 +0,0 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for router connection manager.
|
||||
/// </summary>
|
||||
[Collection("Microservice Integration")]
|
||||
public sealed class ConnectionManagerIntegrationTests
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
|
||||
public ConnectionManagerIntegrationTests(MicroserviceIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
#region Initialization Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_IsInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var connectionManager = _fixture.ConnectionManager;
|
||||
|
||||
// Assert
|
||||
connectionManager.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_HasConnections()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConnectionManager;
|
||||
|
||||
// Act
|
||||
var connections = connectionManager.Connections;
|
||||
|
||||
// Assert
|
||||
connections.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_ConnectionHasCorrectServiceInfo()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConnectionManager;
|
||||
|
||||
// Act
|
||||
var connection = connectionManager.Connections.FirstOrDefault();
|
||||
|
||||
// Assert
|
||||
connection.Should().NotBeNull();
|
||||
connection!.Instance.ServiceName.Should().Be("test-service");
|
||||
connection.Instance.Version.Should().Be("1.0.0");
|
||||
connection.Instance.Region.Should().Be("test-region");
|
||||
connection.Instance.InstanceId.Should().Be("test-instance-001");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_ConnectionHasEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConnectionManager;
|
||||
|
||||
// Act
|
||||
var connection = connectionManager.Connections.FirstOrDefault();
|
||||
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
connection!.Endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_DefaultStatus_IsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
|
||||
// Act
|
||||
var status = connectionManager.CurrentStatus;
|
||||
|
||||
// Assert
|
||||
status.Should().Be(InstanceHealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_CanChangeStatus()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
var originalStatus = connectionManager.CurrentStatus;
|
||||
|
||||
// Act
|
||||
connectionManager.CurrentStatus = InstanceHealthStatus.Degraded;
|
||||
var newStatus = connectionManager.CurrentStatus;
|
||||
|
||||
// Cleanup
|
||||
connectionManager.CurrentStatus = originalStatus;
|
||||
|
||||
// Assert
|
||||
newStatus.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(InstanceHealthStatus.Healthy)]
|
||||
[InlineData(InstanceHealthStatus.Degraded)]
|
||||
[InlineData(InstanceHealthStatus.Draining)]
|
||||
[InlineData(InstanceHealthStatus.Unhealthy)]
|
||||
public void ConnectionManager_AcceptsAllStatusValues(InstanceHealthStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
var originalStatus = connectionManager.CurrentStatus;
|
||||
|
||||
// Act
|
||||
connectionManager.CurrentStatus = status;
|
||||
var actualStatus = connectionManager.CurrentStatus;
|
||||
|
||||
// Cleanup
|
||||
connectionManager.CurrentStatus = originalStatus;
|
||||
|
||||
// Assert
|
||||
actualStatus.Should().Be(status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metrics Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_InFlightRequestCount_InitiallyZero()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
|
||||
// Act
|
||||
var count = connectionManager.InFlightRequestCount;
|
||||
|
||||
// Assert
|
||||
count.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_ErrorRate_InitiallyZero()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
|
||||
// Act
|
||||
var errorRate = connectionManager.ErrorRate;
|
||||
|
||||
// Assert
|
||||
errorRate.Should().BeGreaterOrEqualTo(0);
|
||||
errorRate.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_CanSetInFlightRequestCount()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
var originalCount = connectionManager.InFlightRequestCount;
|
||||
|
||||
// Act
|
||||
connectionManager.InFlightRequestCount = 42;
|
||||
var newCount = connectionManager.InFlightRequestCount;
|
||||
|
||||
// Cleanup
|
||||
connectionManager.InFlightRequestCount = originalCount;
|
||||
|
||||
// Assert
|
||||
newCount.Should().Be(42);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_CanSetErrorRate()
|
||||
{
|
||||
// Arrange
|
||||
var connectionManager = _fixture.ConcreteConnectionManager;
|
||||
var originalRate = connectionManager.ErrorRate;
|
||||
|
||||
// Act
|
||||
connectionManager.ErrorRate = 0.15;
|
||||
var newRate = connectionManager.ErrorRate;
|
||||
|
||||
// Cleanup
|
||||
connectionManager.ErrorRate = originalRate;
|
||||
|
||||
// Assert
|
||||
newRate.Should().Be(0.15);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Router.Common;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end routing tests: message published → routed to correct consumer → ack received.
|
||||
/// Tests the complete routing flow from request to response through the router.
|
||||
/// </summary>
|
||||
[Collection("Microservice Integration")]
|
||||
public sealed class EndToEndRoutingTests
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
|
||||
public EndToEndRoutingTests(MicroserviceIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
#region Basic Request/Response Flow
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Route_EchoEndpoint_IsRegistered()
|
||||
{
|
||||
// Arrange & Act - Verify endpoint is registered for routing
|
||||
var endpointRegistry = _fixture.EndpointRegistry;
|
||||
var endpoints = endpointRegistry.GetAllEndpoints().ToList();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().Contain(e => e.Path == "/echo" && e.Method == "POST");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Route_GetUserEndpoint_MatchesPathPattern()
|
||||
{
|
||||
// Act
|
||||
var endpointRegistry = _fixture.EndpointRegistry;
|
||||
var endpoints = endpointRegistry.GetAllEndpoints().ToList();
|
||||
|
||||
// Assert - Path pattern endpoint is registered
|
||||
var getUserEndpoint = endpoints.FirstOrDefault(e =>
|
||||
e.Path.Contains("{userId}") && e.Method == "GET");
|
||||
|
||||
getUserEndpoint.Should().NotBeNull();
|
||||
getUserEndpoint!.Path.Should().Be("/users/{userId}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Route_CreateUserEndpoint_PreservesCorrelationId()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var requestFrame = new RequestFrame
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
RequestId = correlationId,
|
||||
Method = "POST",
|
||||
Path = "/users",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new CreateUserRequest("Test", "test@example.com")))
|
||||
};
|
||||
|
||||
// Act
|
||||
var convertedFrame = FrameConverter.ToFrame(requestFrame);
|
||||
var roundTripped = FrameConverter.ToRequestFrame(convertedFrame);
|
||||
|
||||
// Assert - Correlation ID preserved through routing
|
||||
roundTripped.Should().NotBeNull();
|
||||
roundTripped!.CorrelationId.Should().Be(correlationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Registration Verification
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointRegistry_ContainsAllTestEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var expectedEndpoints = new[]
|
||||
{
|
||||
("POST", "/echo"),
|
||||
("GET", "/users/{userId}"),
|
||||
("POST", "/users"),
|
||||
("POST", "/slow"),
|
||||
("POST", "/fail"),
|
||||
("POST", "/stream"),
|
||||
("DELETE", "/admin/reset"),
|
||||
("GET", "/quick")
|
||||
};
|
||||
|
||||
// Act
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
|
||||
// Assert
|
||||
foreach (var (method, path) in expectedEndpoints)
|
||||
{
|
||||
endpoints.Should().Contain(e => e.Method == method && e.Path == path,
|
||||
$"Expected endpoint {method} {path} to be registered");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointRegistry_EachEndpointHasUniqueMethodPath()
|
||||
{
|
||||
// Act
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
var methodPathPairs = endpoints.Select(e => $"{e.Method}:{e.Path}").ToList();
|
||||
|
||||
// Assert - No duplicates
|
||||
methodPathPairs.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Manager State
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_HasActiveConnections()
|
||||
{
|
||||
// Act
|
||||
var connections = _fixture.ConnectionManager.Connections.ToList();
|
||||
|
||||
// Assert
|
||||
connections.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConnectionManager_ConnectionsHaveInstanceInfo()
|
||||
{
|
||||
// Act
|
||||
var connections = _fixture.ConnectionManager.Connections.ToList();
|
||||
var firstConnection = connections.First();
|
||||
|
||||
// Assert
|
||||
firstConnection.Instance.Should().NotBeNull();
|
||||
firstConnection.Instance.ServiceName.Should().Be("test-service");
|
||||
firstConnection.Instance.Version.Should().Be("1.0.0");
|
||||
firstConnection.Instance.Region.Should().Be("test-region");
|
||||
firstConnection.Instance.InstanceId.Should().Be("test-instance-001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Frame Protocol Integration
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Frame_RequestSerializationRoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var original = new RequestFrame
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
RequestId = correlationId,
|
||||
Method = "POST",
|
||||
Path = "/echo",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Custom-Header"] = "test-value"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes("{\"message\":\"test\"}")
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored.Should().NotBeNull();
|
||||
restored!.CorrelationId.Should().Be(original.CorrelationId);
|
||||
restored.Method.Should().Be(original.Method);
|
||||
restored.Path.Should().Be(original.Path);
|
||||
restored.Headers.Should().BeEquivalentTo(original.Headers);
|
||||
restored.Payload.ToArray().Should().BeEquivalentTo(original.Payload.ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Frame_ResponseSerializationRoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
var original = new ResponseFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
StatusCode = 200,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json"
|
||||
},
|
||||
Payload = Encoding.UTF8.GetBytes("{\"result\":\"ok\"}")
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(original);
|
||||
var restored = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
restored.Should().NotBeNull();
|
||||
restored!.RequestId.Should().Be(original.RequestId);
|
||||
restored.StatusCode.Should().Be(original.StatusCode);
|
||||
restored.Headers.Should().BeEquivalentTo(original.Headers);
|
||||
restored.Payload.ToArray().Should().BeEquivalentTo(original.Payload.ToArray());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Matching Integration
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("GET", "/users/123", true)]
|
||||
[InlineData("GET", "/users/abc-def", true)]
|
||||
[InlineData("GET", "/users/", false)]
|
||||
[InlineData("POST", "/users/123", false)] // Wrong method
|
||||
[InlineData("GET", "/user/123", false)] // Wrong path
|
||||
public void PathMatching_VariableSegment_MatchesCorrectly(string method, string path, bool shouldMatch)
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
var getUserEndpoint = endpoints.First(e => e.Path.Contains("{userId}"));
|
||||
|
||||
// Act
|
||||
var matcher = new PathMatcher(getUserEndpoint.Path);
|
||||
var isMatch = matcher.IsMatch(path) && method == getUserEndpoint.Method;
|
||||
|
||||
// Assert
|
||||
isMatch.Should().Be(shouldMatch);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("/echo", "/echo", true)]
|
||||
[InlineData("/echo", "/Echo", true)] // PathMatcher is case-insensitive
|
||||
[InlineData("/users", "/users", true)]
|
||||
[InlineData("/users", "/users/", true)] // PathMatcher normalizes trailing slashes
|
||||
[InlineData("/admin/reset", "/admin/reset", true)]
|
||||
public void PathMatching_ExactPath_MatchesCorrectly(string pattern, string path, bool shouldMatch)
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher(pattern);
|
||||
|
||||
// Act
|
||||
var isMatch = matcher.IsMatch(path);
|
||||
|
||||
// Assert
|
||||
isMatch.Should().Be(shouldMatch);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Determinism
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Routing_SameRequest_AlwaysSameEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var method = "POST";
|
||||
var path = "/echo";
|
||||
|
||||
// Act - Find matching endpoint multiple times
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
var results = new List<string>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var match = endpoints.FirstOrDefault(e => e.Method == method && e.Path == path);
|
||||
if (match is not null)
|
||||
{
|
||||
results.Add($"{match.Method}:{match.Path}");
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Always same result
|
||||
results.Should().OnlyContain(r => r == "POST:/echo");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Routing_MultipleEndpoints_DeterministicOrdering()
|
||||
{
|
||||
// Act - Get endpoints multiple times
|
||||
var ordering1 = _fixture.EndpointRegistry.GetAllEndpoints().Select(e => $"{e.Method}:{e.Path}").ToList();
|
||||
var ordering2 = _fixture.EndpointRegistry.GetAllEndpoints().Select(e => $"{e.Method}:{e.Path}").ToList();
|
||||
var ordering3 = _fixture.EndpointRegistry.GetAllEndpoints().Select(e => $"{e.Method}:{e.Path}").ToList();
|
||||
|
||||
// Assert - Order is stable
|
||||
ordering1.Should().BeEquivalentTo(ordering2, options => options.WithStrictOrdering());
|
||||
ordering2.Should().BeEquivalentTo(ordering3, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Routing
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointRegistry_ContainsFailEndpoint()
|
||||
{
|
||||
// Act
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
|
||||
// Assert - Fail endpoint is registered and routable
|
||||
endpoints.Should().Contain(e => e.Path == "/fail" && e.Method == "POST");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Routing_UnknownPath_NoMatchingEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var unknownPath = "/nonexistent/endpoint";
|
||||
|
||||
// Act
|
||||
var endpoints = _fixture.EndpointRegistry.GetAllEndpoints().ToList();
|
||||
var match = endpoints.FirstOrDefault(e =>
|
||||
{
|
||||
var matcher = new PathMatcher(e.Path);
|
||||
return matcher.IsMatch(unknownPath);
|
||||
});
|
||||
|
||||
// Assert
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for endpoint registry and discovery.
|
||||
/// </summary>
|
||||
[Collection("Microservice Integration")]
|
||||
public sealed class EndpointRegistryIntegrationTests
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
|
||||
public EndpointRegistryIntegrationTests(MicroserviceIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
#region Endpoint Discovery Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Registry_ContainsAllTestEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("POST", "/echo")]
|
||||
[InlineData("GET", "/users/123")]
|
||||
[InlineData("POST", "/users")]
|
||||
[InlineData("POST", "/slow")]
|
||||
[InlineData("POST", "/fail")]
|
||||
[InlineData("POST", "/stream")]
|
||||
[InlineData("DELETE", "/admin/reset")]
|
||||
[InlineData("GET", "/quick")]
|
||||
public void Registry_FindsEndpoint_ByMethodAndPath(string method, string path)
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
var found = registry.TryMatch(method, path, out var match);
|
||||
|
||||
// Assert
|
||||
found.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.Endpoint.Method.Should().Be(method);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Registry_ReturnsNull_ForUnknownEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
var found = registry.TryMatch("GET", "/unknown", out var match);
|
||||
|
||||
// Assert
|
||||
found.Should().BeFalse();
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Registry_MatchesPathParameters()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
var found = registry.TryMatch("GET", "/users/12345", out var match);
|
||||
|
||||
// Assert
|
||||
found.Should().BeTrue();
|
||||
match!.Endpoint.Path.Should().Be("/users/{userId}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Registry_ExtractsPathParameters()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
registry.TryMatch("GET", "/users/abc123", out var match);
|
||||
|
||||
// Assert
|
||||
match.Should().NotBeNull();
|
||||
match!.PathParameters.Should().ContainKey("userId");
|
||||
match.PathParameters["userId"].Should().Be("abc123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Metadata Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Endpoint_HasCorrectTimeout()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
registry.TryMatch("GET", "/quick", out var quickMatch);
|
||||
registry.TryMatch("POST", "/slow", out var slowMatch);
|
||||
|
||||
// Assert
|
||||
quickMatch!.Endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(5));
|
||||
slowMatch!.Endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Endpoint_HasCorrectStreamingFlag()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
registry.TryMatch("POST", "/stream", out var streamMatch);
|
||||
registry.TryMatch("POST", "/echo", out var echoMatch);
|
||||
|
||||
// Assert
|
||||
streamMatch!.Endpoint.SupportsStreaming.Should().BeTrue();
|
||||
echoMatch!.Endpoint.SupportsStreaming.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Endpoint_HasCorrectClaims()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
registry.TryMatch("DELETE", "/admin/reset", out var adminMatch);
|
||||
registry.TryMatch("POST", "/echo", out var echoMatch);
|
||||
|
||||
// Assert
|
||||
adminMatch!.Endpoint.RequiringClaims.Should().HaveCount(2);
|
||||
adminMatch.Endpoint.RequiringClaims.Should().Contain(c => c.Type == "admin");
|
||||
adminMatch.Endpoint.RequiringClaims.Should().Contain(c => c.Type == "write");
|
||||
echoMatch!.Endpoint.RequiringClaims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Endpoint_HasCorrectHandlerType()
|
||||
{
|
||||
// Arrange
|
||||
var registry = _fixture.EndpointRegistry;
|
||||
|
||||
// Act
|
||||
registry.TryMatch("POST", "/echo", out var match);
|
||||
|
||||
// Assert
|
||||
match!.Endpoint.HandlerType.Should().Be(typeof(EchoEndpoint));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
using FrameType = StellaOps.Router.Common.Enums.FrameType;
|
||||
|
||||
namespace StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture that sets up a microservice with InMemory transport for integration testing.
|
||||
/// The fixture wires up both the server (Gateway) side and client (Microservice) side
|
||||
/// to enable full end-to-end request/response flow testing.
|
||||
/// </summary>
|
||||
public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service provider for the test microservice.
|
||||
/// </summary>
|
||||
public IServiceProvider Services => _host?.Services ?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint registry.
|
||||
/// </summary>
|
||||
public IEndpointRegistry EndpointRegistry => Services.GetRequiredService<IEndpointRegistry>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the router connection manager interface.
|
||||
/// </summary>
|
||||
public IRouterConnectionManager ConnectionManager => Services.GetRequiredService<IRouterConnectionManager>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the concrete router connection manager for accessing additional properties.
|
||||
/// </summary>
|
||||
public RouterConnectionManager ConcreteConnectionManager => (RouterConnectionManager)ConnectionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the InMemory transport client for direct testing.
|
||||
/// </summary>
|
||||
public InMemoryTransportClient TransportClient => Services.GetRequiredService<InMemoryTransportClient>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the InMemory transport server (gateway side).
|
||||
/// </summary>
|
||||
public InMemoryTransportServer TransportServer => Services.GetRequiredService<InMemoryTransportServer>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the InMemory connection registry shared by server and client.
|
||||
/// </summary>
|
||||
public InMemoryConnectionRegistry ConnectionRegistry => Services.GetRequiredService<InMemoryConnectionRegistry>();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
// Add InMemory transport (shared registry, server + client)
|
||||
builder.Services.AddInMemoryTransport();
|
||||
|
||||
// Add microservice with test discovery provider
|
||||
builder.Services.AddStellaMicroservice<TestEndpointDiscoveryProvider>(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test-region";
|
||||
options.InstanceId = "test-instance-001";
|
||||
options.HeartbeatInterval = TimeSpan.FromMilliseconds(100);
|
||||
options.ReconnectBackoffInitial = TimeSpan.FromMilliseconds(10);
|
||||
options.ReconnectBackoffMax = TimeSpan.FromMilliseconds(100);
|
||||
options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5100,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
});
|
||||
|
||||
// Register test endpoint handlers - basic endpoints
|
||||
builder.Services.AddScoped<EchoEndpoint>();
|
||||
builder.Services.AddScoped<GetUserEndpoint>();
|
||||
builder.Services.AddScoped<CreateUserEndpoint>();
|
||||
builder.Services.AddScoped<SlowEndpoint>();
|
||||
builder.Services.AddScoped<FailEndpoint>();
|
||||
builder.Services.AddScoped<StreamEndpoint>();
|
||||
builder.Services.AddScoped<AdminResetEndpoint>();
|
||||
builder.Services.AddScoped<QuickEndpoint>();
|
||||
|
||||
// Register test endpoint handlers - binding test endpoints
|
||||
builder.Services.AddScoped<SearchEndpoint>(); // Query params
|
||||
builder.Services.AddScoped<GetItemEndpoint>(); // Multiple path params
|
||||
builder.Services.AddScoped<HeaderTestEndpoint>(); // Header binding
|
||||
builder.Services.AddScoped<LoginEndpoint>(); // Form data
|
||||
builder.Services.AddScoped<UpdateResourceEndpoint>(); // Combined binding
|
||||
builder.Services.AddScoped<ListItemsEndpoint>(); // Pagination
|
||||
builder.Services.AddScoped<RawEchoEndpoint>(); // Raw body
|
||||
builder.Services.AddScoped<DeleteItemEndpoint>(); // DELETE with path
|
||||
builder.Services.AddScoped<PatchItemEndpoint>(); // PATCH with path + body
|
||||
|
||||
_host = builder.Build();
|
||||
|
||||
// Start the transport server first (simulates Gateway)
|
||||
var server = _host.Services.GetRequiredService<InMemoryTransportServer>();
|
||||
await server.StartAsync(CancellationToken.None);
|
||||
|
||||
// Then start the host (which starts the microservice and connects)
|
||||
await _host.StartAsync();
|
||||
|
||||
// Wait for microservice to connect and register endpoints
|
||||
await WaitForConnectionAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the microservice to establish connection and register endpoints.
|
||||
/// </summary>
|
||||
private async Task WaitForConnectionAsync()
|
||||
{
|
||||
var maxWait = TimeSpan.FromSeconds(5);
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (DateTime.UtcNow - start < maxWait)
|
||||
{
|
||||
if (ConnectionRegistry.Count > 0)
|
||||
{
|
||||
var connections = ConnectionRegistry.GetAllConnections();
|
||||
if (connections.Any(c => c.Endpoints.Count > 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Microservice did not connect within timeout");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request through the transport and waits for a response.
|
||||
/// This simulates the Gateway dispatching a request to the microservice.
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
/// <param name="path">Request path.</param>
|
||||
/// <param name="payload">Request body (optional).</param>
|
||||
/// <param name="headers">Request headers (optional).</param>
|
||||
/// <param name="timeout">Request timeout.</param>
|
||||
/// <returns>The response frame.</returns>
|
||||
public async Task<ResponseFrame> SendRequestAsync(
|
||||
string method,
|
||||
string path,
|
||||
object? payload = null,
|
||||
Dictionary<string, string>? headers = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(30);
|
||||
|
||||
// Find the connection
|
||||
var connections = ConnectionRegistry.GetAllConnections();
|
||||
var connection = connections.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No microservice connection available");
|
||||
|
||||
// Build request frame
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var requestPayload = payload is not null
|
||||
? Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))
|
||||
: Array.Empty<byte>();
|
||||
|
||||
var requestHeaders = headers ?? new Dictionary<string, string>();
|
||||
if (payload is not null && !requestHeaders.ContainsKey("Content-Type"))
|
||||
{
|
||||
requestHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
var requestFrame = new RequestFrame
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
RequestId = correlationId,
|
||||
Method = method,
|
||||
Path = path,
|
||||
Headers = requestHeaders,
|
||||
Payload = requestPayload,
|
||||
TimeoutSeconds = (int)timeout.Value.TotalSeconds
|
||||
};
|
||||
|
||||
var frame = FrameConverter.ToFrame(requestFrame);
|
||||
|
||||
// Send through the transport server to the microservice
|
||||
await TransportServer.SendToMicroserviceAsync(connection.ConnectionId, frame, CancellationToken.None);
|
||||
|
||||
// Wait for response via the channel, filtering out heartbeats
|
||||
var channel = ConnectionRegistry.GetRequiredChannel(connection.ConnectionId);
|
||||
using var cts = new CancellationTokenSource(timeout.Value);
|
||||
|
||||
Frame responseFrame;
|
||||
while (true)
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Convert to ResponseFrame
|
||||
var response = FrameConverter.ToResponseFrame(responseFrame);
|
||||
if (response is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid response frame type: {responseFrame.Type}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a response payload to the specified type.
|
||||
/// </summary>
|
||||
public T? DeserializeResponse<T>(ResponseFrame response)
|
||||
{
|
||||
if (response.Payload.IsEmpty)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(response.Payload.Span, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new request builder for fluent request construction.
|
||||
/// Supports all minimal API parameter binding patterns:
|
||||
/// - JSON body (FromBody)
|
||||
/// - Query parameters (FromQuery)
|
||||
/// - Path parameters (FromRoute)
|
||||
/// - Headers (FromHeader)
|
||||
/// - Form data (FromForm)
|
||||
/// - Raw body
|
||||
/// </summary>
|
||||
public RequestBuilder CreateRequest(string method, string path) => new(this, method, path);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request built by the RequestBuilder.
|
||||
/// </summary>
|
||||
internal async Task<ResponseFrame> SendRequestAsync(RequestBuilder builder, TimeSpan? timeout = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(30);
|
||||
|
||||
// Find the connection
|
||||
var connections = ConnectionRegistry.GetAllConnections();
|
||||
var connection = connections.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("No microservice connection available");
|
||||
|
||||
// Build the full path with query parameters
|
||||
var fullPath = builder.BuildFullPath();
|
||||
|
||||
// Build request frame
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var (payload, contentType) = builder.BuildPayload();
|
||||
|
||||
var requestHeaders = new Dictionary<string, string>(builder.Headers);
|
||||
if (contentType is not null && !requestHeaders.ContainsKey("Content-Type"))
|
||||
{
|
||||
requestHeaders["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
var requestFrame = new RequestFrame
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
RequestId = correlationId,
|
||||
Method = builder.Method,
|
||||
Path = fullPath,
|
||||
Headers = requestHeaders,
|
||||
Payload = payload,
|
||||
TimeoutSeconds = (int)timeout.Value.TotalSeconds
|
||||
};
|
||||
|
||||
var frame = FrameConverter.ToFrame(requestFrame);
|
||||
|
||||
// Send through the transport server to the microservice
|
||||
await TransportServer.SendToMicroserviceAsync(connection.ConnectionId, frame, CancellationToken.None);
|
||||
|
||||
// Wait for response via the channel, filtering out heartbeats
|
||||
var channel = ConnectionRegistry.GetRequiredChannel(connection.ConnectionId);
|
||||
using var cts = new CancellationTokenSource(timeout.Value);
|
||||
|
||||
Frame responseFrame;
|
||||
while (true)
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Convert to ResponseFrame
|
||||
var response = FrameConverter.ToResponseFrame(responseFrame);
|
||||
if (response is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid response frame type: {responseFrame.Type}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_host is not null)
|
||||
{
|
||||
await _host.StopAsync();
|
||||
|
||||
// Stop the transport server
|
||||
var server = _host.Services.GetService<InMemoryTransportServer>();
|
||||
if (server is not null)
|
||||
{
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for sharing fixture across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Microservice Integration")]
|
||||
public class MicroserviceIntegrationCollection : ICollectionFixture<MicroserviceIntegrationFixture>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fluent request builder supporting all minimal API parameter binding patterns.
|
||||
/// </summary>
|
||||
public sealed class RequestBuilder
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
private readonly Dictionary<string, string> _queryParams = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _formData = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _headers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private object? _jsonBody;
|
||||
private byte[]? _rawBody;
|
||||
private string? _rawContentType;
|
||||
|
||||
internal string Method { get; }
|
||||
internal string BasePath { get; }
|
||||
internal IReadOnlyDictionary<string, string> Headers => _headers;
|
||||
|
||||
internal RequestBuilder(MicroserviceIntegrationFixture fixture, string method, string path)
|
||||
{
|
||||
_fixture = fixture;
|
||||
Method = method;
|
||||
BasePath = path;
|
||||
}
|
||||
|
||||
#region Query Parameters (FromQuery)
|
||||
|
||||
/// <summary>
|
||||
/// Adds a query parameter. Maps to [FromQuery] in minimal APIs.
|
||||
/// </summary>
|
||||
public RequestBuilder WithQuery(string name, string value)
|
||||
{
|
||||
_queryParams[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a query parameter with type conversion.
|
||||
/// </summary>
|
||||
public RequestBuilder WithQuery<T>(string name, T value) where T : notnull
|
||||
{
|
||||
_queryParams[name] = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple query parameters from a dictionary.
|
||||
/// </summary>
|
||||
public RequestBuilder WithQueries(IEnumerable<KeyValuePair<string, string>> parameters)
|
||||
{
|
||||
foreach (var (key, value) in parameters)
|
||||
{
|
||||
_queryParams[key] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds query parameters from an anonymous object.
|
||||
/// </summary>
|
||||
public RequestBuilder WithQueries(object queryObject)
|
||||
{
|
||||
foreach (var prop in queryObject.GetType().GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(queryObject);
|
||||
if (value is not null)
|
||||
{
|
||||
_queryParams[prop.Name] = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Headers (FromHeader)
|
||||
|
||||
/// <summary>
|
||||
/// Adds a request header. Maps to [FromHeader] in minimal APIs.
|
||||
/// </summary>
|
||||
public RequestBuilder WithHeader(string name, string value)
|
||||
{
|
||||
_headers[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple headers.
|
||||
/// </summary>
|
||||
public RequestBuilder WithHeaders(IEnumerable<KeyValuePair<string, string>> headers)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
_headers[key] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Authorization header.
|
||||
/// </summary>
|
||||
public RequestBuilder WithAuthorization(string scheme, string value)
|
||||
{
|
||||
_headers["Authorization"] = $"{scheme} {value}";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Bearer token authorization.
|
||||
/// </summary>
|
||||
public RequestBuilder WithBearerToken(string token) => WithAuthorization("Bearer", token);
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Body (FromBody)
|
||||
|
||||
/// <summary>
|
||||
/// Sets JSON request body. Maps to [FromBody] in minimal APIs.
|
||||
/// </summary>
|
||||
public RequestBuilder WithJsonBody<T>(T body)
|
||||
{
|
||||
_jsonBody = body;
|
||||
_formData.Clear();
|
||||
_rawBody = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Form Data (FromForm)
|
||||
|
||||
/// <summary>
|
||||
/// Adds form field. Maps to [FromForm] in minimal APIs.
|
||||
/// Uses application/x-www-form-urlencoded encoding.
|
||||
/// </summary>
|
||||
public RequestBuilder WithFormField(string name, string value)
|
||||
{
|
||||
_formData[name] = value;
|
||||
_jsonBody = null;
|
||||
_rawBody = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds form field with type conversion.
|
||||
/// </summary>
|
||||
public RequestBuilder WithFormField<T>(string name, T value) where T : notnull
|
||||
{
|
||||
return WithFormField(name, Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple form fields from a dictionary.
|
||||
/// </summary>
|
||||
public RequestBuilder WithFormFields(IEnumerable<KeyValuePair<string, string>> fields)
|
||||
{
|
||||
foreach (var (key, value) in fields)
|
||||
{
|
||||
_formData[key] = value;
|
||||
}
|
||||
_jsonBody = null;
|
||||
_rawBody = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds form fields from an anonymous object.
|
||||
/// </summary>
|
||||
public RequestBuilder WithFormFields(object formObject)
|
||||
{
|
||||
foreach (var prop in formObject.GetType().GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(formObject);
|
||||
if (value is not null)
|
||||
{
|
||||
_formData[prop.Name] = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
_jsonBody = null;
|
||||
_rawBody = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Raw Body
|
||||
|
||||
/// <summary>
|
||||
/// Sets raw request body with explicit content type.
|
||||
/// </summary>
|
||||
public RequestBuilder WithRawBody(byte[] body, string contentType = "application/octet-stream")
|
||||
{
|
||||
_rawBody = body;
|
||||
_rawContentType = contentType;
|
||||
_jsonBody = null;
|
||||
_formData.Clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets raw text body.
|
||||
/// </summary>
|
||||
public RequestBuilder WithTextBody(string text, string contentType = "text/plain; charset=utf-8")
|
||||
{
|
||||
return WithRawBody(Encoding.UTF8.GetBytes(text), contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets XML body.
|
||||
/// </summary>
|
||||
public RequestBuilder WithXmlBody(string xml)
|
||||
{
|
||||
return WithRawBody(Encoding.UTF8.GetBytes(xml), "application/xml; charset=utf-8");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Execution
|
||||
|
||||
/// <summary>
|
||||
/// Sends the request and returns the response.
|
||||
/// </summary>
|
||||
public Task<ResponseFrame> SendAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
return _fixture.SendRequestAsync(this, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the request and deserializes the response.
|
||||
/// </summary>
|
||||
public async Task<T?> SendAsync<T>(TimeSpan? timeout = null)
|
||||
{
|
||||
var response = await SendAsync(timeout);
|
||||
return _fixture.DeserializeResponse<T>(response);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Builds the full path including query string.
|
||||
/// </summary>
|
||||
internal string BuildFullPath()
|
||||
{
|
||||
if (_queryParams.Count == 0)
|
||||
{
|
||||
return BasePath;
|
||||
}
|
||||
|
||||
var queryString = string.Join("&", _queryParams.Select(kvp =>
|
||||
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
|
||||
return $"{BasePath}?{queryString}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the request payload and determines content type.
|
||||
/// </summary>
|
||||
internal (byte[] Payload, string? ContentType) BuildPayload()
|
||||
{
|
||||
// Raw body takes precedence
|
||||
if (_rawBody is not null)
|
||||
{
|
||||
return (_rawBody, _rawContentType);
|
||||
}
|
||||
|
||||
// Form data
|
||||
if (_formData.Count > 0)
|
||||
{
|
||||
var formContent = string.Join("&", _formData.Select(kvp =>
|
||||
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
return (Encoding.UTF8.GetBytes(formContent), "application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
// JSON body
|
||||
if (_jsonBody is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_jsonBody, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
return (Encoding.UTF8.GetBytes(json), "application/json");
|
||||
}
|
||||
|
||||
// No body
|
||||
return (Array.Empty<byte>(), null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user