sprints work
This commit is contained in:
@@ -176,4 +176,109 @@ public static class CanonJson
|
||||
var canonical = Canonicalize(obj);
|
||||
return Sha256Prefixed(canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes an object with version marker for content-addressed hashing.
|
||||
/// The version marker is embedded as the first field in the canonical JSON,
|
||||
/// ensuring stable hashes even if canonicalization logic evolves.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="obj">The object to canonicalize.</param>
|
||||
/// <param name="version">Canonicalization version (default: Current).</param>
|
||||
/// <returns>UTF-8 encoded canonical JSON bytes with version marker.</returns>
|
||||
public static byte[] CanonicalizeVersioned<T>(T obj, string version = CanonVersion.Current)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false });
|
||||
|
||||
WriteElementVersioned(doc.RootElement, writer, version);
|
||||
writer.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes an object with version marker using custom serializer options.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="obj">The object to canonicalize.</param>
|
||||
/// <param name="options">JSON serializer options to use for initial serialization.</param>
|
||||
/// <param name="version">Canonicalization version (default: Current).</param>
|
||||
/// <returns>UTF-8 encoded canonical JSON bytes with version marker.</returns>
|
||||
public static byte[] CanonicalizeVersioned<T>(T obj, JsonSerializerOptions options, string version = CanonVersion.Current)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj, options);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false });
|
||||
|
||||
WriteElementVersioned(doc.RootElement, writer, version);
|
||||
writer.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteElementVersioned(JsonElement el, Utf8JsonWriter w, string version)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
|
||||
// Write version marker first (underscore prefix ensures lexicographic first position)
|
||||
w.WriteString(CanonVersion.VersionFieldName, version);
|
||||
|
||||
// Write remaining properties sorted
|
||||
foreach (var prop in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
w.WritePropertyName(prop.Name);
|
||||
WriteElementSorted(prop.Value, w);
|
||||
}
|
||||
w.WriteEndObject();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-object root: wrap in object with version marker
|
||||
w.WriteStartObject();
|
||||
w.WriteString(CanonVersion.VersionFieldName, version);
|
||||
w.WritePropertyName("_value");
|
||||
WriteElementSorted(el, w);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of versioned canonical representation.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="obj">The object to hash.</param>
|
||||
/// <param name="version">Canonicalization version (default: Current).</param>
|
||||
/// <returns>64-character lowercase hex string.</returns>
|
||||
public static string HashVersioned<T>(T obj, string version = CanonVersion.Current)
|
||||
{
|
||||
var canonical = CanonicalizeVersioned(obj, version);
|
||||
return Sha256Hex(canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes prefixed SHA-256 hash of versioned canonical representation.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="obj">The object to hash.</param>
|
||||
/// <param name="version">Canonicalization version (default: Current).</param>
|
||||
/// <returns>Hash string with "sha256:" prefix.</returns>
|
||||
public static string HashVersionedPrefixed<T>(T obj, string version = CanonVersion.Current)
|
||||
{
|
||||
var canonical = CanonicalizeVersioned(obj, version);
|
||||
return Sha256Prefixed(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
87
src/__Libraries/StellaOps.Canonical.Json/CanonVersion.cs
Normal file
87
src/__Libraries/StellaOps.Canonical.Json/CanonVersion.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
namespace StellaOps.Canonical.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalization version identifiers for content-addressed hashing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Version markers are embedded in canonical JSON to ensure hash stability across
|
||||
/// algorithm evolution. When canonicalization logic changes (bug fixes, spec updates,
|
||||
/// optimizations), a new version constant is introduced, allowing:
|
||||
/// <list type="bullet">
|
||||
/// <item>Verifiers to select the correct canonicalization algorithm</item>
|
||||
/// <item>Graceful migration without invalidating existing hashes</item>
|
||||
/// <item>Clear audit trail of which algorithm produced each hash</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class CanonVersion
|
||||
{
|
||||
/// <summary>
|
||||
/// Version 1: RFC 8785 JSON Canonicalization Scheme (JCS) with:
|
||||
/// <list type="bullet">
|
||||
/// <item>Ordinal key sorting (case-sensitive, lexicographic)</item>
|
||||
/// <item>No whitespace or formatting variations</item>
|
||||
/// <item>UTF-8 encoding without BOM</item>
|
||||
/// <item>IEEE 754 number formatting</item>
|
||||
/// <item>Minimal escape sequences in strings</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public const string V1 = "stella:canon:v1";
|
||||
|
||||
/// <summary>
|
||||
/// Field name for version marker in canonical JSON.
|
||||
/// Underscore prefix ensures it sorts first lexicographically,
|
||||
/// making version detection a simple prefix check.
|
||||
/// </summary>
|
||||
public const string VersionFieldName = "_canonVersion";
|
||||
|
||||
/// <summary>
|
||||
/// Current default version for new hashes.
|
||||
/// All new content-addressed IDs use this version.
|
||||
/// </summary>
|
||||
public const string Current = V1;
|
||||
|
||||
/// <summary>
|
||||
/// Prefix bytes for detecting versioned canonical JSON.
|
||||
/// Versioned JSON starts with: {"_canonVersion":"
|
||||
/// </summary>
|
||||
internal static ReadOnlySpan<byte> VersionedPrefixBytes => "{\"_canonVersion\":\""u8;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if canonical JSON bytes are versioned (contain version marker).
|
||||
/// </summary>
|
||||
/// <param name="canonicalJson">UTF-8 encoded canonical JSON bytes.</param>
|
||||
/// <returns>True if the JSON contains a version marker at the expected position.</returns>
|
||||
public static bool IsVersioned(ReadOnlySpan<byte> canonicalJson)
|
||||
{
|
||||
// Versioned canonical JSON always starts with: {"_canonVersion":"stella:canon:v
|
||||
// Minimum length: {"_canonVersion":"stella:canon:v1"} = 35 bytes
|
||||
return canonicalJson.Length >= 35 &&
|
||||
canonicalJson.StartsWith(VersionedPrefixBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the version string from versioned canonical JSON.
|
||||
/// </summary>
|
||||
/// <param name="canonicalJson">UTF-8 encoded canonical JSON bytes.</param>
|
||||
/// <returns>The version string, or null if not versioned or invalid format.</returns>
|
||||
public static string? ExtractVersion(ReadOnlySpan<byte> canonicalJson)
|
||||
{
|
||||
if (!IsVersioned(canonicalJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the closing quote after the version value
|
||||
var prefixLength = VersionedPrefixBytes.Length;
|
||||
var remaining = canonicalJson[prefixLength..];
|
||||
|
||||
var quoteIndex = remaining.IndexOf((byte)'"');
|
||||
if (quoteIndex <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var versionBytes = remaining[..quoteIndex];
|
||||
return System.Text.Encoding.UTF8.GetString(versionBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for merging endpoint overrides with ASP.NET-specific authorization mapping strategy support.
|
||||
/// Extends the base <see cref="IEndpointOverrideMerger"/> to support strategy-aware claim merging.
|
||||
/// </summary>
|
||||
public interface IAspNetEndpointOverrideMerger : IEndpointOverrideMerger
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges endpoint overrides from YAML configuration with ASP.NET-discovered endpoints,
|
||||
/// supporting different authorization mapping strategies.
|
||||
/// </summary>
|
||||
public sealed class AspNetEndpointOverrideMerger : IAspNetEndpointOverrideMerger
|
||||
{
|
||||
private readonly StellaRouterBridgeOptions _bridgeOptions;
|
||||
private readonly ILogger<AspNetEndpointOverrideMerger> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AspNetEndpointOverrideMerger"/> class.
|
||||
/// </summary>
|
||||
public AspNetEndpointOverrideMerger(
|
||||
StellaRouterBridgeOptions bridgeOptions,
|
||||
ILogger<AspNetEndpointOverrideMerger> logger)
|
||||
{
|
||||
_bridgeOptions = bridgeOptions ?? throw new ArgumentNullException(nameof(bridgeOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<EndpointDescriptor> Merge(
|
||||
IReadOnlyList<EndpointDescriptor> codeEndpoints,
|
||||
MicroserviceYamlConfig? yamlConfig)
|
||||
{
|
||||
if (yamlConfig == null || yamlConfig.Endpoints.Count == 0)
|
||||
{
|
||||
// No YAML config - use code endpoints as-is
|
||||
return ApplyStrategyForCodeOnly(codeEndpoints);
|
||||
}
|
||||
|
||||
WarnUnmatchedOverrides(codeEndpoints, yamlConfig);
|
||||
|
||||
return codeEndpoints.Select(ep =>
|
||||
{
|
||||
var yamlOverride = FindMatchingOverride(ep, yamlConfig);
|
||||
return yamlOverride == null
|
||||
? ApplyStrategyForCodeOnly(ep)
|
||||
: MergeEndpoint(ep, yamlOverride);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private IReadOnlyList<EndpointDescriptor> ApplyStrategyForCodeOnly(
|
||||
IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
return _bridgeOptions.AuthorizationMapping switch
|
||||
{
|
||||
AuthorizationMappingStrategy.YamlOnly =>
|
||||
// Clear code claims when YamlOnly is configured
|
||||
endpoints.Select(e => e with { RequiringClaims = [] }).ToList(),
|
||||
|
||||
_ => endpoints // AspNetMetadataOnly or Hybrid - keep code claims
|
||||
};
|
||||
}
|
||||
|
||||
private EndpointDescriptor ApplyStrategyForCodeOnly(EndpointDescriptor endpoint)
|
||||
{
|
||||
return _bridgeOptions.AuthorizationMapping switch
|
||||
{
|
||||
AuthorizationMappingStrategy.YamlOnly =>
|
||||
// Clear code claims when YamlOnly is configured
|
||||
endpoint with { RequiringClaims = [] },
|
||||
|
||||
_ => endpoint // AspNetMetadataOnly or Hybrid - keep code claims
|
||||
};
|
||||
}
|
||||
|
||||
private static EndpointOverrideConfig? FindMatchingOverride(
|
||||
EndpointDescriptor endpoint,
|
||||
MicroserviceYamlConfig yamlConfig)
|
||||
{
|
||||
return yamlConfig.Endpoints.FirstOrDefault(y =>
|
||||
string.Equals(y.Method, endpoint.Method, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(y.Path, endpoint.Path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private EndpointDescriptor MergeEndpoint(
|
||||
EndpointDescriptor codeDefault,
|
||||
EndpointOverrideConfig yamlOverride)
|
||||
{
|
||||
// Determine claims based on strategy
|
||||
var mergedClaims = MergeClaimsBasedOnStrategy(codeDefault.RequiringClaims, yamlOverride);
|
||||
|
||||
var merged = codeDefault with
|
||||
{
|
||||
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
|
||||
SupportsStreaming = yamlOverride.SupportsStreaming ?? codeDefault.SupportsStreaming,
|
||||
RequiringClaims = mergedClaims
|
||||
};
|
||||
|
||||
LogMergeDetails(merged, yamlOverride, mergedClaims.Count);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private IReadOnlyList<ClaimRequirement> MergeClaimsBasedOnStrategy(
|
||||
IReadOnlyList<ClaimRequirement> codeClaims,
|
||||
EndpointOverrideConfig yamlOverride)
|
||||
{
|
||||
var yamlClaims = yamlOverride.RequiringClaims?
|
||||
.Select(c => c.ToClaimRequirement())
|
||||
.ToList() ?? [];
|
||||
|
||||
return _bridgeOptions.AuthorizationMapping switch
|
||||
{
|
||||
AuthorizationMappingStrategy.YamlOnly =>
|
||||
// Use only YAML claims (code claims are ignored)
|
||||
yamlClaims,
|
||||
|
||||
AuthorizationMappingStrategy.AspNetMetadataOnly =>
|
||||
// Use only code claims (YAML claims are ignored)
|
||||
codeClaims.ToList(),
|
||||
|
||||
AuthorizationMappingStrategy.Hybrid =>
|
||||
// Hybrid: YAML claims supplement code claims
|
||||
// If YAML specifies any claims, they replace code claims for that endpoint
|
||||
// This allows YAML to either add to or override code claims
|
||||
yamlClaims.Count > 0
|
||||
? MergeClaimsHybrid(codeClaims, yamlClaims)
|
||||
: codeClaims.ToList(),
|
||||
|
||||
_ => codeClaims.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges code and YAML claims in Hybrid mode.
|
||||
/// YAML claims take precedence for the same claim type/value, but code claims are retained
|
||||
/// for types not specified in YAML.
|
||||
/// </summary>
|
||||
private static List<ClaimRequirement> MergeClaimsHybrid(
|
||||
IReadOnlyList<ClaimRequirement> codeClaims,
|
||||
List<ClaimRequirement> yamlClaims)
|
||||
{
|
||||
// Start with YAML claims (they take precedence)
|
||||
var merged = new List<ClaimRequirement>(yamlClaims);
|
||||
|
||||
// Get claim types already specified in YAML
|
||||
var yamlClaimTypes = yamlClaims
|
||||
.Select(c => c.Type)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Add code claims for types NOT already in YAML
|
||||
foreach (var codeClaim in codeClaims)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(codeClaim.Type) &&
|
||||
!yamlClaimTypes.Contains(codeClaim.Type))
|
||||
{
|
||||
merged.Add(codeClaim);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private void LogMergeDetails(
|
||||
EndpointDescriptor merged,
|
||||
EndpointOverrideConfig yamlOverride,
|
||||
int claimCount)
|
||||
{
|
||||
if (yamlOverride.GetDefaultTimeoutAsTimeSpan().HasValue ||
|
||||
yamlOverride.SupportsStreaming.HasValue ||
|
||||
yamlOverride.RequiringClaims?.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Applied YAML overrides to endpoint {Method} {Path}: " +
|
||||
"Timeout={Timeout}, Streaming={Streaming}, Claims={Claims} (Strategy={Strategy})",
|
||||
merged.Method,
|
||||
merged.Path,
|
||||
merged.DefaultTimeout,
|
||||
merged.SupportsStreaming,
|
||||
claimCount,
|
||||
_bridgeOptions.AuthorizationMapping);
|
||||
}
|
||||
}
|
||||
|
||||
private void WarnUnmatchedOverrides(
|
||||
IReadOnlyList<EndpointDescriptor> codeEndpoints,
|
||||
MicroserviceYamlConfig yamlConfig)
|
||||
{
|
||||
var codeKeys = codeEndpoints
|
||||
.Select(e => (Method: e.Method.ToUpperInvariant(), Path: e.Path.ToLowerInvariant()))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var yamlEntry in yamlConfig.Endpoints)
|
||||
{
|
||||
var key = (Method: yamlEntry.Method.ToUpperInvariant(), Path: yamlEntry.Path.ToLowerInvariant());
|
||||
if (!codeKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"YAML override for {Method} {Path} does not match any discovered endpoint. " +
|
||||
"YAML cannot create endpoints, only modify existing ones.",
|
||||
yamlEntry.Method,
|
||||
yamlEntry.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ public static class StellaRouterBridgeExtensions
|
||||
// Register authorization claim mapper
|
||||
services.TryAddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();
|
||||
|
||||
// Register ASP.NET-specific endpoint override merger (supports authorization mapping strategy)
|
||||
services.TryAddSingleton<IAspNetEndpointOverrideMerger, AspNetEndpointOverrideMerger>();
|
||||
|
||||
// Register endpoint discovery provider
|
||||
services.TryAddSingleton<IAspNetEndpointDiscoveryProvider, AspNetCoreEndpointDiscoveryProvider>();
|
||||
|
||||
@@ -65,12 +68,23 @@ public static class StellaRouterBridgeExtensions
|
||||
// Wire into Router SDK by adding microservice services (unless disabled)
|
||||
if (registerMicroserviceServices)
|
||||
{
|
||||
// First register the ASP.NET-specific merger as the IEndpointOverrideMerger
|
||||
// This ensures the base EndpointDiscoveryService uses our strategy-aware merger
|
||||
services.AddSingleton<IEndpointOverrideMerger>(sp =>
|
||||
sp.GetRequiredService<IAspNetEndpointOverrideMerger>());
|
||||
|
||||
services.AddStellaMicroservice(microserviceOptions =>
|
||||
{
|
||||
microserviceOptions.ServiceName = options.ServiceName;
|
||||
microserviceOptions.Version = options.Version;
|
||||
microserviceOptions.Region = options.Region;
|
||||
microserviceOptions.InstanceId = options.InstanceId;
|
||||
|
||||
// Map YAML config path for endpoint override merging
|
||||
if (!string.IsNullOrWhiteSpace(options.YamlConfigPath))
|
||||
{
|
||||
microserviceOptions.ConfigFilePath = options.YamlConfigPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Microservice.AspNetCore;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AspNetEndpointOverrideMerger"/>.
|
||||
/// Verifies authorization mapping strategy handling and claim merging.
|
||||
/// </summary>
|
||||
public sealed class AspNetEndpointOverrideMergerTests
|
||||
{
|
||||
#region No YAML Config
|
||||
|
||||
[Fact]
|
||||
public void Merge_NullYamlConfig_ReturnsCodeEndpointsUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [new ClaimRequirement { Type = "role", Value = "admin" }]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, null);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_EmptyYamlEndpoints_ReturnsCodeEndpointsUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [new ClaimRequirement { Type = "role", Value = "admin" }]));
|
||||
var yaml = new MicroserviceYamlConfig { Endpoints = [] };
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region YamlOnly Strategy
|
||||
|
||||
[Fact]
|
||||
public void Merge_YamlOnlyStrategy_NoOverrides_ClearsCodeClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.YamlOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" },
|
||||
new ClaimRequirement { Type = "scope", Value = "read" }
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, null);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Empty(result[0].RequiringClaims); // Code claims cleared
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_YamlOnlyStrategy_WithOverrides_UsesOnlyYamlClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.YamlOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [
|
||||
("scope", "read"),
|
||||
("scope", "write")
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(2, result[0].RequiringClaims.Count);
|
||||
Assert.All(result[0].RequiringClaims, c => Assert.Equal("scope", c.Type));
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Value == "read");
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Value == "write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_YamlOnlyStrategy_NonMatchingOverride_ClearsCodeClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.YamlOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("POST", "/api/other", [("scope", "write")])); // No match
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Empty(result[0].RequiringClaims); // Code claims cleared (YamlOnly)
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AspNetMetadataOnly Strategy
|
||||
|
||||
[Fact]
|
||||
public void Merge_AspNetMetadataOnlyStrategy_NoOverrides_KeepsCodeClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.AspNetMetadataOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, null);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AspNetMetadataOnlyStrategy_WithOverrides_IgnoresYamlClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.AspNetMetadataOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [
|
||||
("scope", "read"),
|
||||
("scope", "write")
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims); // Only code claims kept
|
||||
Assert.Equal("role", result[0].RequiringClaims[0].Type);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AspNetMetadataOnlyStrategy_StillAppliesNonClaimOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.AspNetMetadataOnly);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [], TimeSpan.FromSeconds(30), false));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [], "60s", true));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), result[0].DefaultTimeout); // Timeout applied
|
||||
Assert.True(result[0].SupportsStreaming); // Streaming applied
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hybrid Strategy
|
||||
|
||||
[Fact]
|
||||
public void Merge_HybridStrategy_NoOverrides_KeepsCodeClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, null);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_HybridStrategy_YamlAddsNewClaimType_BothTypesPresent()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [
|
||||
("scope", "read") // Different claim type
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(2, result[0].RequiringClaims.Count);
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "role" && c.Value == "admin");
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "scope" && c.Value == "read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_HybridStrategy_YamlOverridesSameClaimType_YamlTakesPrecedence()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" },
|
||||
new ClaimRequirement { Type = "scope", Value = "read" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [
|
||||
("role", "superuser") // Same type as code
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(2, result[0].RequiringClaims.Count);
|
||||
// YAML 'role' claim takes precedence (code 'role' is dropped)
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "role" && c.Value == "superuser");
|
||||
// Code 'scope' claim is retained (not in YAML)
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "scope" && c.Value == "read");
|
||||
// Code 'admin' role is NOT present
|
||||
Assert.DoesNotContain(result[0].RequiringClaims, c => c.Value == "admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_HybridStrategy_YamlEmptyClaims_KeepsCodeClaims()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" }
|
||||
]));
|
||||
var yaml = new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointOverrideConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [] // Empty, not null
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_HybridStrategy_MultipleClaimTypesInYaml_OnlyOverridesMatchingTypes()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [
|
||||
new ClaimRequirement { Type = "role", Value = "admin" },
|
||||
new ClaimRequirement { Type = "department", Value = "IT" },
|
||||
new ClaimRequirement { Type = "level", Value = "senior" }
|
||||
]));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [
|
||||
("role", "manager"),
|
||||
("scope", "read")
|
||||
]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(4, result[0].RequiringClaims.Count);
|
||||
// YAML claims
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "role" && c.Value == "manager");
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "scope" && c.Value == "read");
|
||||
// Retained code claims (types not in YAML)
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "department" && c.Value == "IT");
|
||||
Assert.Contains(result[0].RequiringClaims, c => c.Type == "level" && c.Value == "senior");
|
||||
// Dropped code claim (type overridden by YAML)
|
||||
Assert.DoesNotContain(result[0].RequiringClaims, c => c.Type == "role" && c.Value == "admin");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timeout and Streaming Overrides
|
||||
|
||||
[Fact]
|
||||
public void Merge_AppliesTimeoutOverride()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [], TimeSpan.FromSeconds(30), false));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [], "60s", null));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), result[0].DefaultTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AppliesStreamingOverride()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [], TimeSpan.FromSeconds(30), false));
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [], null, true));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.True(result[0].SupportsStreaming);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_NullOverrideProperties_KeepsCodeDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [], TimeSpan.FromSeconds(30), true));
|
||||
var yaml = new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointOverrideConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = null,
|
||||
SupportsStreaming = null
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), result[0].DefaultTimeout);
|
||||
Assert.True(result[0].SupportsStreaming);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Case Insensitive Matching
|
||||
|
||||
[Fact]
|
||||
public void Merge_MatchesEndpointsIgnoringCase()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/Users", []));
|
||||
var yaml = CreateYamlConfig(
|
||||
("get", "/API/USERS", [("role", "admin")]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Single(result[0].RequiringClaims);
|
||||
Assert.Equal("admin", result[0].RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Endpoints
|
||||
|
||||
[Fact]
|
||||
public void Merge_MultipleEndpoints_AppliesCorrectOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/users", [new ClaimRequirement { Type = "role", Value = "viewer" }]),
|
||||
("POST", "/api/users", [new ClaimRequirement { Type = "role", Value = "admin" }]),
|
||||
("DELETE", "/api/users/{id}", []));
|
||||
|
||||
var yaml = CreateYamlConfig(
|
||||
("GET", "/api/users", [("scope", "read")]),
|
||||
("DELETE", "/api/users/{id}", [("role", "superadmin")]));
|
||||
|
||||
// Act
|
||||
var result = merger.Merge(endpoints, yaml);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
// GET - has YAML override (adds scope, keeps role)
|
||||
var getEndpoint = result.First(e => e.Method == "GET");
|
||||
Assert.Equal(2, getEndpoint.RequiringClaims.Count);
|
||||
Assert.Contains(getEndpoint.RequiringClaims, c => c.Type == "scope");
|
||||
Assert.Contains(getEndpoint.RequiringClaims, c => c.Type == "role");
|
||||
|
||||
// POST - no YAML override (keeps code claims)
|
||||
var postEndpoint = result.First(e => e.Method == "POST");
|
||||
Assert.Single(postEndpoint.RequiringClaims);
|
||||
Assert.Equal("admin", postEndpoint.RequiringClaims[0].Value);
|
||||
|
||||
// DELETE - has YAML override (adds role)
|
||||
var deleteEndpoint = result.First(e => e.Method == "DELETE");
|
||||
Assert.Single(deleteEndpoint.RequiringClaims);
|
||||
Assert.Equal("superadmin", deleteEndpoint.RequiringClaims[0].Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Fact]
|
||||
public void Merge_ProducesDeterministicOrder()
|
||||
{
|
||||
// Arrange
|
||||
var merger = CreateMerger(AuthorizationMappingStrategy.Hybrid);
|
||||
var endpoints = CreateEndpoints(
|
||||
("GET", "/api/a", []),
|
||||
("POST", "/api/b", []),
|
||||
("DELETE", "/api/c", []));
|
||||
var yaml = new MicroserviceYamlConfig { Endpoints = [] };
|
||||
|
||||
// Act - run multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => merger.Merge(endpoints, yaml))
|
||||
.ToList();
|
||||
|
||||
// Assert - all results identical
|
||||
var firstResult = results[0];
|
||||
Assert.All(results, r =>
|
||||
{
|
||||
Assert.Equal(firstResult.Count, r.Count);
|
||||
for (int i = 0; i < firstResult.Count; i++)
|
||||
{
|
||||
Assert.Equal(firstResult[i].Method, r[i].Method);
|
||||
Assert.Equal(firstResult[i].Path, r[i].Path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static AspNetEndpointOverrideMerger CreateMerger(AuthorizationMappingStrategy strategy)
|
||||
{
|
||||
var options = new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "TestService",
|
||||
Version = "1.0.0",
|
||||
Region = "test-region",
|
||||
AuthorizationMapping = strategy
|
||||
};
|
||||
return new AspNetEndpointOverrideMerger(
|
||||
options,
|
||||
NullLogger<AspNetEndpointOverrideMerger>.Instance);
|
||||
}
|
||||
|
||||
private static List<EndpointDescriptor> CreateEndpoints(
|
||||
params (string Method, string Path, List<ClaimRequirement> Claims)[] endpoints)
|
||||
{
|
||||
return endpoints.Select(e => new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "TestService",
|
||||
Version = "1.0.0",
|
||||
Method = e.Method,
|
||||
Path = e.Path,
|
||||
RequiringClaims = e.Claims,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
SupportsStreaming = false
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<EndpointDescriptor> CreateEndpoints(
|
||||
params (string Method, string Path, List<ClaimRequirement> Claims, TimeSpan Timeout, bool Streaming)[] endpoints)
|
||||
{
|
||||
return endpoints.Select(e => new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "TestService",
|
||||
Version = "1.0.0",
|
||||
Method = e.Method,
|
||||
Path = e.Path,
|
||||
RequiringClaims = e.Claims,
|
||||
DefaultTimeout = e.Timeout,
|
||||
SupportsStreaming = e.Streaming
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static MicroserviceYamlConfig CreateYamlConfig(
|
||||
params (string Method, string Path, List<(string Type, string Value)> Claims)[] overrides)
|
||||
{
|
||||
return new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints = overrides.Select(o => new EndpointOverrideConfig
|
||||
{
|
||||
Method = o.Method,
|
||||
Path = o.Path,
|
||||
RequiringClaims = o.Claims.Select(c => new ClaimRequirementConfig
|
||||
{
|
||||
Type = c.Type,
|
||||
Value = c.Value
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MicroserviceYamlConfig CreateYamlConfig(
|
||||
params (string Method, string Path, List<(string Type, string Value)> Claims, string? Timeout, bool? Streaming)[] overrides)
|
||||
{
|
||||
return new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints = overrides.Select(o => new EndpointOverrideConfig
|
||||
{
|
||||
Method = o.Method,
|
||||
Path = o.Path,
|
||||
RequiringClaims = o.Claims.Select(c => new ClaimRequirementConfig
|
||||
{
|
||||
Type = c.Type,
|
||||
Value = c.Value
|
||||
}).ToList(),
|
||||
DefaultTimeout = o.Timeout,
|
||||
SupportsStreaming = o.Streaming
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
|
||||
namespace StellaOps.Microservice.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for ASP.NET Minimal APIs parameter binding through the Router bridge.
|
||||
/// Tests all binding patterns: FromQuery, FromRoute, FromHeader, FromBody, FromForm.
|
||||
/// </summary>
|
||||
public sealed class MinimalApiBindingIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private AspNetRouterRequestDispatcher? _dispatcher;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public MinimalApiBindingIntegrationTests()
|
||||
{
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Build a real WebApplication with Minimal APIs
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ApplicationName = "MinimalApiTestApp"
|
||||
});
|
||||
|
||||
// Register test services
|
||||
builder.Services.AddSingleton(new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "test",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
});
|
||||
|
||||
_app = builder.Build();
|
||||
|
||||
// Register all test endpoints
|
||||
RegisterMinimalApiEndpoints(_app);
|
||||
|
||||
// Start the app so endpoints are registered in the data source
|
||||
await _app.StartAsync();
|
||||
|
||||
// Get the endpoint data source - now it should have all endpoints
|
||||
var endpointDataSource = _app.Services.GetRequiredService<EndpointDataSource>();
|
||||
var bridgeOptions = _app.Services.GetRequiredService<StellaRouterBridgeOptions>();
|
||||
|
||||
_dispatcher = new AspNetRouterRequestDispatcher(
|
||||
_app.Services,
|
||||
endpointDataSource,
|
||||
bridgeOptions,
|
||||
NullLogger<AspNetRouterRequestDispatcher>.Instance);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterMinimalApiEndpoints(WebApplication app)
|
||||
{
|
||||
// === FromQuery Binding ===
|
||||
|
||||
// GET /search?query=xxx&page=1&pageSize=10&includeDeleted=false
|
||||
app.MapGet("/search", (
|
||||
[FromQuery] string? query,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
[FromQuery] bool includeDeleted = false) =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
Query = query,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
IncludeDeleted = includeDeleted,
|
||||
TotalResults = 42
|
||||
});
|
||||
});
|
||||
|
||||
// GET /items?offset=0&limit=20&sortBy=id&sortOrder=asc
|
||||
app.MapGet("/items", (
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string sortBy = "id",
|
||||
[FromQuery] string sortOrder = "asc") =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
Offset = offset,
|
||||
Limit = limit,
|
||||
SortBy = sortBy,
|
||||
SortOrder = sortOrder,
|
||||
Items = new[] { "item1", "item2", "item3" }
|
||||
});
|
||||
});
|
||||
|
||||
// === FromRoute Binding ===
|
||||
|
||||
// GET /users/{userId}
|
||||
app.MapGet("/users/{userId}", ([FromRoute] string userId) =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
UserId = userId,
|
||||
Name = $"User-{userId}",
|
||||
Email = $"user-{userId}@example.com"
|
||||
});
|
||||
});
|
||||
|
||||
// GET /categories/{categoryId}/items/{itemId}
|
||||
app.MapGet("/categories/{categoryId}/items/{itemId}", (
|
||||
[FromRoute] string categoryId,
|
||||
[FromRoute] string itemId) =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
CategoryId = categoryId,
|
||||
ItemId = itemId,
|
||||
Name = $"Item-{itemId}-in-{categoryId}",
|
||||
Price = 19.99m
|
||||
});
|
||||
});
|
||||
|
||||
// === FromHeader Binding ===
|
||||
|
||||
// GET /headers
|
||||
app.MapGet("/headers", (
|
||||
[FromHeader(Name = "Authorization")] string? authorization,
|
||||
[FromHeader(Name = "X-Request-Id")] string? xRequestId,
|
||||
[FromHeader(Name = "X-Custom-Header")] string? xCustomHeader,
|
||||
[FromHeader(Name = "Accept-Language")] string? acceptLanguage,
|
||||
HttpContext context) =>
|
||||
{
|
||||
var allHeaders = context.Request.Headers
|
||||
.ToDictionary(h => h.Key, h => h.Value.ToString());
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Authorization = authorization,
|
||||
XRequestId = xRequestId,
|
||||
XCustomHeader = xCustomHeader,
|
||||
AcceptLanguage = acceptLanguage,
|
||||
AllHeaders = allHeaders
|
||||
});
|
||||
});
|
||||
|
||||
// === FromBody Binding (JSON) ===
|
||||
|
||||
// POST /echo - use anonymous type for simplicity with JSON binding
|
||||
app.MapPost("/echo", async (HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var bodyText = await reader.ReadToEndAsync();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var request = JsonSerializer.Deserialize<EchoRequestDto>(bodyText, options);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Echo = $"Echo: {request?.Message}",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
});
|
||||
|
||||
// POST /users
|
||||
app.MapPost("/users", async (HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var bodyText = await reader.ReadToEndAsync();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var request = JsonSerializer.Deserialize<CreateUserRequestDto>(bodyText, options);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Success = true,
|
||||
UserId = Guid.NewGuid().ToString(),
|
||||
Name = request?.Name,
|
||||
Email = request?.Email
|
||||
});
|
||||
});
|
||||
|
||||
// POST /raw-echo
|
||||
app.MapPost("/raw-echo", async (HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
|
||||
context.Response.Headers["X-Echo-Length"] = body.Length.ToString();
|
||||
context.Response.ContentType = context.Request.ContentType ?? "text/plain";
|
||||
await context.Response.WriteAsync(body);
|
||||
});
|
||||
|
||||
// === FromForm Binding ===
|
||||
|
||||
// POST /login
|
||||
app.MapPost("/login", ([FromForm] string username, [FromForm] string password, [FromForm] bool rememberMe, HttpContext context) =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
RememberMe = rememberMe,
|
||||
ContentType = context.Request.ContentType
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// === Combined Binding (Path + Query + Body) ===
|
||||
|
||||
// PUT /resources/{resourceId}?format=json&verbose=true
|
||||
app.MapPut("/resources/{resourceId}", async (
|
||||
HttpContext context,
|
||||
[FromRoute] string resourceId,
|
||||
[FromQuery] string? format = null,
|
||||
[FromQuery] bool verbose = false) =>
|
||||
{
|
||||
UpdateResourceRequestDto? body = null;
|
||||
if (context.Request.ContentLength > 0 || context.Request.ContentType?.Contains("json") == true)
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var bodyText = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrEmpty(bodyText))
|
||||
{
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
body = JsonSerializer.Deserialize<UpdateResourceRequestDto>(bodyText, options);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ResourceId = resourceId,
|
||||
Format = format,
|
||||
Verbose = verbose,
|
||||
Name = body?.Name,
|
||||
Description = body?.Description,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
});
|
||||
|
||||
// === HTTP Method Tests ===
|
||||
|
||||
// DELETE /items/{itemId}
|
||||
app.MapDelete("/items/{itemId}", ([FromRoute] string itemId) =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
ItemId = itemId,
|
||||
Deleted = true,
|
||||
DeletedAt = DateTime.UtcNow
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /items/{itemId}
|
||||
app.MapPatch("/items/{itemId}", async ([FromRoute] string itemId, HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var bodyText = await reader.ReadToEndAsync();
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var request = JsonSerializer.Deserialize<PatchItemRequestDto>(bodyText, options);
|
||||
|
||||
var updatedFields = new List<string>();
|
||||
if (request?.Name is not null) updatedFields.Add("name");
|
||||
if (request?.Price.HasValue == true) updatedFields.Add("price");
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ItemId = itemId,
|
||||
Name = request?.Name,
|
||||
Price = request?.Price,
|
||||
UpdatedFields = updatedFields
|
||||
});
|
||||
});
|
||||
|
||||
// GET /quick (simple endpoint)
|
||||
app.MapGet("/quick", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
||||
}
|
||||
|
||||
|
||||
#region FromQuery Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_StringParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/search?query=test-search-term");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("test-search-term", body.GetProperty("query").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_IntParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/search?page=5&pageSize=25");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal(5, body.GetProperty("page").GetInt32());
|
||||
Assert.Equal(25, body.GetProperty("pageSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_BoolParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/search?includeDeleted=true");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.True(body.GetProperty("includeDeleted").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_MultipleParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/search?query=widgets&page=3&pageSize=50&includeDeleted=false");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("widgets", body.GetProperty("query").GetString());
|
||||
Assert.Equal(3, body.GetProperty("page").GetInt32());
|
||||
Assert.Equal(50, body.GetProperty("pageSize").GetInt32());
|
||||
Assert.False(body.GetProperty("includeDeleted").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_DefaultValues_UsedWhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/items");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal(0, body.GetProperty("offset").GetInt32());
|
||||
Assert.Equal(20, body.GetProperty("limit").GetInt32());
|
||||
Assert.Equal("id", body.GetProperty("sortBy").GetString());
|
||||
Assert.Equal("asc", body.GetProperty("sortOrder").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_OverrideDefaults_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/items?offset=100&limit=50&sortBy=name&sortOrder=desc");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal(100, body.GetProperty("offset").GetInt32());
|
||||
Assert.Equal(50, body.GetProperty("limit").GetInt32());
|
||||
Assert.Equal("name", body.GetProperty("sortBy").GetString());
|
||||
Assert.Equal("desc", body.GetProperty("sortOrder").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_UrlEncodedValues_BindCorrectly()
|
||||
{
|
||||
// Arrange - URL encode "hello world & test"
|
||||
var request = CreateRequest("GET", "/search?query=hello%20world%20%26%20test");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("hello world & test", body.GetProperty("query").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromRoute Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_SinglePathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/users/user-123");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("user-123", body.GetProperty("userId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_MultiplePathParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/categories/electronics/items/widget-456");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("electronics", body.GetProperty("categoryId").GetString());
|
||||
Assert.Equal("widget-456", body.GetProperty("itemId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_NumericPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/users/12345");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("12345", body.GetProperty("userId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_GuidPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
var request = CreateRequest("GET", $"/users/{guid}");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal(guid, body.GetProperty("userId").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromHeader Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_AuthorizationHeader_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/headers", headers: new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer test-token-12345"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("Bearer test-token-12345", body.GetProperty("authorization").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_CustomHeaders_BindCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/headers", headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Request-Id"] = "req-abc-123",
|
||||
["X-Custom-Header"] = "custom-value",
|
||||
["Accept-Language"] = "en-US"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("req-abc-123", body.GetProperty("xRequestId").GetString());
|
||||
Assert.Equal("custom-value", body.GetProperty("xCustomHeader").GetString());
|
||||
Assert.Equal("en-US", body.GetProperty("acceptLanguage").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_AllHeadersAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/headers", headers: new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer jwt-token",
|
||||
["X-Request-Id"] = "correlation-123"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
var allHeaders = body.GetProperty("allHeaders");
|
||||
Assert.True(allHeaders.TryGetProperty("Authorization", out _));
|
||||
Assert.True(allHeaders.TryGetProperty("X-Request-Id", out _));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromBody Tests (JSON)
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_SimpleJson_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { message = "Hello, World!" }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/echo", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Contains("Hello, World!", body.GetProperty("echo").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_ComplexObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "John Doe", email = "john@example.com" }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/users", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("John Doe", body.GetProperty("name").GetString());
|
||||
Assert.Equal("john@example.com", body.GetProperty("email").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_RawBody_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var textBody = "This is plain text content";
|
||||
var request = CreateRequest("POST", "/raw-echo", body: textBody, contentType: "text/plain");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
Assert.Equal(textBody, responseBody);
|
||||
Assert.True(response.Headers.ContainsKey("X-Echo-Length"));
|
||||
Assert.Equal(textBody.Length.ToString(), response.Headers["X-Echo-Length"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_LargePayload_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var largeMessage = new string('x', 10000);
|
||||
var jsonBody = JsonSerializer.Serialize(new { message = largeMessage }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/echo", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Contains(largeMessage, body.GetProperty("echo").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_UnicodeContent_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var unicodeMessage = "Hello 世界! Привет мир! مرحبا";
|
||||
var jsonBody = JsonSerializer.Serialize(new { message = unicodeMessage }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/echo", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Contains(unicodeMessage, body.GetProperty("echo").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromForm Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_SimpleFormData_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var formBody = "username=testuser&password=secret123&rememberMe=true";
|
||||
var request = CreateRequest("POST", "/login", body: formBody, contentType: "application/x-www-form-urlencoded");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("testuser", body.GetProperty("username").GetString());
|
||||
Assert.Equal("secret123", body.GetProperty("password").GetString());
|
||||
Assert.True(body.GetProperty("rememberMe").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_UrlEncodedSpecialChars_BindsCorrectly()
|
||||
{
|
||||
// Arrange - Special characters that need URL encoding
|
||||
var formBody = "username=test&password=p%40ss%3Dword%26special%21&rememberMe=false";
|
||||
var request = CreateRequest("POST", "/login", body: formBody, contentType: "application/x-www-form-urlencoded");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("p@ss=word&special!", body.GetProperty("password").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_ContentType_IsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var formBody = "username=test&password=test&rememberMe=false";
|
||||
var request = CreateRequest("POST", "/login", body: formBody, contentType: "application/x-www-form-urlencoded");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Contains("application/x-www-form-urlencoded", body.GetProperty("contentType").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combined Binding Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Updated Resource", description = "New description" }, _jsonOptions);
|
||||
var request = CreateRequest("PUT", "/resources/res-123", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("res-123", body.GetProperty("resourceId").GetString());
|
||||
Assert.Equal("Updated Resource", body.GetProperty("name").GetString());
|
||||
Assert.Equal("New description", body.GetProperty("description").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathQueryAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Full Update", description = "Verbose mode" }, _jsonOptions);
|
||||
var request = CreateRequest("PUT", "/resources/res-456?format=json&verbose=true", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("res-456", body.GetProperty("resourceId").GetString());
|
||||
Assert.Equal("json", body.GetProperty("format").GetString());
|
||||
Assert.True(body.GetProperty("verbose").GetBoolean());
|
||||
Assert.Equal("Full Update", body.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Method Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGet_ReturnsData()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/users/get-test-user");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("get-test-user", body.GetProperty("userId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPost_CreatesResource()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "New User", email = "new@example.com" }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/users", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPut_UpdatesResource()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Updated Name", description = "Updated via PUT" }, _jsonOptions);
|
||||
var request = CreateRequest("PUT", "/resources/update-me", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("update-me", body.GetProperty("resourceId").GetString());
|
||||
Assert.Equal("Updated Name", body.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_PartialUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Patched Name", price = 29.99 }, _jsonOptions);
|
||||
var request = CreateRequest("PATCH", "/items/patch-item-1", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("patch-item-1", body.GetProperty("itemId").GetString());
|
||||
Assert.Equal("Patched Name", body.GetProperty("name").GetString());
|
||||
Assert.Equal(29.99m, body.GetProperty("price").GetDecimal());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_OnlySpecifiedFields_Updated()
|
||||
{
|
||||
// Arrange - Only update name, not price
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Only Name Updated" }, _jsonOptions);
|
||||
var request = CreateRequest("PATCH", "/items/partial-patch", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
var updatedFields = body.GetProperty("updatedFields");
|
||||
Assert.Contains("name", updatedFields.EnumerateArray().Select(e => e.GetString()));
|
||||
Assert.DoesNotContain("price", updatedFields.EnumerateArray().Select(e => e.GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpDelete_RemovesResource()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("DELETE", "/items/delete-me-123");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("delete-me-123", body.GetProperty("itemId").GetString());
|
||||
Assert.True(body.GetProperty("deleted").GetBoolean());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task SimpleEndpoint_NoParameters()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/quick");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("OK", body.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonExistentEndpoint_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/nonexistent/endpoint");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(404, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongHttpMethod_Returns404()
|
||||
{
|
||||
// Arrange - /quick is GET only
|
||||
var request = CreateRequest("POST", "/quick");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(404, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRequests_AllSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var tasks = Enumerable.Range(1, 10)
|
||||
.Select(i => _dispatcher!.DispatchAsync(
|
||||
CreateRequest("GET", $"/users/concurrent-user-{i}"),
|
||||
CancellationToken.None));
|
||||
|
||||
// Act
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
Assert.All(responses, r => Assert.Equal(200, r.StatusCode));
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
var body = DeserializeResponse(responses[i]);
|
||||
Assert.Equal($"concurrent-user-{i + 1}", body.GetProperty("userId").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private RequestFrame CreateRequest(
|
||||
string method,
|
||||
string path,
|
||||
string? body = null,
|
||||
string? contentType = null,
|
||||
Dictionary<string, string>? headers = null)
|
||||
{
|
||||
var requestHeaders = headers ?? new Dictionary<string, string>();
|
||||
|
||||
if (contentType is not null)
|
||||
{
|
||||
requestHeaders["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Headers = requestHeaders,
|
||||
Payload = body is not null ? Encoding.UTF8.GetBytes(body) : ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private JsonElement DeserializeResponse(ResponseFrame response)
|
||||
{
|
||||
if (response.Payload.IsEmpty)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<JsonElement>(response.Payload.Span, _jsonOptions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request/Response Types (DTOs for endpoint handlers)
|
||||
|
||||
// DTOs used by endpoint handlers - using classes for easier JSON deserialization
|
||||
private class EchoRequestDto
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
private class CreateUserRequestDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
private class UpdateResourceRequestDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
private class PatchItemRequestDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
|
||||
namespace StellaOps.Microservice.AspNetCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Full integration tests for StellaOps Router bridge.
|
||||
/// Tests the complete flow: Program.cs registration → service startup → endpoint discovery → request dispatch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <c>registerMicroserviceServices: false</c> to avoid needing a real transport during tests.
|
||||
/// The existing <see cref="MinimalApiBindingIntegrationTests"/> provide comprehensive dispatch coverage.
|
||||
/// </remarks>
|
||||
public sealed class StellaRouterBridgeIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private AspNetRouterRequestDispatcher? _dispatcher;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Build a real WebApplication with the Router bridge
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ApplicationName = "BridgeIntegrationTestApp"
|
||||
});
|
||||
|
||||
// Add authorization services (required by DefaultAuthorizationClaimMapper)
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Configure the Router bridge (mimics a real Program.cs)
|
||||
// Use registerMicroserviceServices: false to avoid needing IMicroserviceTransport
|
||||
builder.Services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "integration-test-service";
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test-region";
|
||||
options.AuthorizationMapping = AuthorizationMappingStrategy.Hybrid;
|
||||
options.DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
options.OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated;
|
||||
}, registerMicroserviceServices: false);
|
||||
|
||||
_app = builder.Build();
|
||||
|
||||
// Register test endpoints (mimics a real Program.cs)
|
||||
RegisterTestEndpoints(_app);
|
||||
|
||||
// Enable the bridge middleware
|
||||
_app.UseStellaRouterBridge();
|
||||
|
||||
await _app.StartAsync();
|
||||
|
||||
// Create dispatcher manually for dispatch tests (since we disabled microservice services)
|
||||
var endpointDataSource = _app.Services.GetRequiredService<EndpointDataSource>();
|
||||
var bridgeOptions = _app.Services.GetRequiredService<StellaRouterBridgeOptions>();
|
||||
|
||||
_dispatcher = new AspNetRouterRequestDispatcher(
|
||||
_app.Services,
|
||||
endpointDataSource,
|
||||
bridgeOptions,
|
||||
NullLogger<AspNetRouterRequestDispatcher>.Instance);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterTestEndpoints(WebApplication app)
|
||||
{
|
||||
// Public endpoint (no authorization)
|
||||
app.MapGet("/api/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow }));
|
||||
|
||||
// Authenticated endpoint
|
||||
app.MapGet("/api/me", (HttpContext context) =>
|
||||
{
|
||||
var userId = context.User.FindFirst("sub")?.Value ?? "unknown";
|
||||
var tenant = context.User.FindFirst("tenant")?.Value ?? "default";
|
||||
return Results.Ok(new { UserId = userId, Tenant = tenant });
|
||||
});
|
||||
|
||||
// Admin endpoint
|
||||
app.MapGet("/api/admin/stats", [Authorize(Roles = "admin")] () =>
|
||||
{
|
||||
return Results.Ok(new { TotalUsers = 1234, ActiveSessions = 56 });
|
||||
});
|
||||
|
||||
// CRUD operations
|
||||
app.MapGet("/api/items", () => Results.Ok(new { Items = new[] { "item1", "item2" } }));
|
||||
app.MapGet("/api/items/{id}", (string id) => Results.Ok(new { Id = id, Name = $"Item-{id}" }));
|
||||
app.MapPost("/api/items", async (HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
var name = data.GetProperty("name").GetString();
|
||||
return Results.Created($"/api/items/new-id", new { Id = "new-id", Name = name });
|
||||
});
|
||||
app.MapPut("/api/items/{id}", async (string id, HttpContext context) =>
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||
var name = data.GetProperty("name").GetString();
|
||||
return Results.Ok(new { Id = id, Name = name, Updated = true });
|
||||
});
|
||||
app.MapDelete("/api/items/{id}", (string id) => Results.Ok(new { Id = id, Deleted = true }));
|
||||
}
|
||||
|
||||
#region Service Registration Tests
|
||||
|
||||
[Fact]
|
||||
public void Services_RegisteredCorrectly()
|
||||
{
|
||||
// Assert - All required services are registered
|
||||
Assert.NotNull(_app!.Services.GetService<StellaRouterBridgeOptions>());
|
||||
Assert.NotNull(_app.Services.GetService<IAuthorizationClaimMapper>());
|
||||
Assert.NotNull(_app.Services.GetService<IAspNetEndpointDiscoveryProvider>());
|
||||
Assert.NotNull(_app.Services.GetService<IAspNetRouterRequestDispatcher>());
|
||||
Assert.NotNull(_app.Services.GetService<IAspNetEndpointOverrideMerger>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BridgeOptions_ConfiguredCorrectly()
|
||||
{
|
||||
// Act
|
||||
var options = _app!.Services.GetRequiredService<StellaRouterBridgeOptions>();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("integration-test-service", options.ServiceName);
|
||||
Assert.Equal("1.0.0", options.Version);
|
||||
Assert.Equal("test-region", options.Region);
|
||||
Assert.Equal(AuthorizationMappingStrategy.Hybrid, options.AuthorizationMapping);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceId_GeneratedIfNotProvided()
|
||||
{
|
||||
// Act
|
||||
var options = _app!.Services.GetRequiredService<StellaRouterBridgeOptions>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(options.InstanceId);
|
||||
Assert.StartsWith("integration-test-service-", options.InstanceId);
|
||||
Assert.Equal(36, options.InstanceId.Length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Discovery Tests
|
||||
|
||||
[Fact]
|
||||
public void EndpointDiscovery_FindsAllEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var discoveryProvider = _app!.Services.GetRequiredService<IAspNetEndpointDiscoveryProvider>();
|
||||
|
||||
// Act
|
||||
var endpoints = discoveryProvider.DiscoverEndpoints();
|
||||
|
||||
// Assert
|
||||
Assert.True(endpoints.Count >= 6, $"Expected at least 6 endpoints, found {endpoints.Count}");
|
||||
|
||||
// Verify specific endpoints are discovered
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/health");
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/me");
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/admin/stats");
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/items");
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/items/{id}");
|
||||
Assert.Contains(endpoints, e => e.Method == "POST" && e.Path == "/api/items");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointDiscovery_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var discoveryProvider = _app!.Services.GetRequiredService<IAspNetEndpointDiscoveryProvider>();
|
||||
|
||||
// Act - Discover multiple times
|
||||
var endpoints1 = discoveryProvider.DiscoverEndpoints();
|
||||
var endpoints2 = discoveryProvider.DiscoverEndpoints();
|
||||
var endpoints3 = discoveryProvider.DiscoverEndpoints();
|
||||
|
||||
// Assert - All results identical
|
||||
Assert.Equal(endpoints1.Count, endpoints2.Count);
|
||||
Assert.Equal(endpoints2.Count, endpoints3.Count);
|
||||
|
||||
for (int i = 0; i < endpoints1.Count; i++)
|
||||
{
|
||||
Assert.Equal(endpoints1[i].Method, endpoints2[i].Method);
|
||||
Assert.Equal(endpoints1[i].Path, endpoints2[i].Path);
|
||||
Assert.Equal(endpoints2[i].Method, endpoints3[i].Method);
|
||||
Assert.Equal(endpoints2[i].Path, endpoints3[i].Path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointDiscovery_IncludesServiceMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var discoveryProvider = _app!.Services.GetRequiredService<IAspNetEndpointDiscoveryProvider>();
|
||||
|
||||
// Act
|
||||
var endpoints = discoveryProvider.DiscoverEndpoints();
|
||||
var healthEndpoint = endpoints.First(e => e.Path == "/api/health");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("integration-test-service", healthEndpoint.ServiceName);
|
||||
Assert.Equal("1.0.0", healthEndpoint.Version);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request Dispatch Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_SimpleGet_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/api/health");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("Healthy", body.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_GetWithRouteParameter_BindsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/api/items/test-item-123");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("test-item-123", body.GetProperty("id").GetString());
|
||||
Assert.Equal("Item-test-item-123", body.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_PostWithJsonBody_BindsBody()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "New Test Item" }, _jsonOptions);
|
||||
var request = CreateRequest("POST", "/api/items", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(201, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("new-id", body.GetProperty("id").GetString());
|
||||
Assert.Equal("New Test Item", body.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_PutWithRouteAndBody_BindsBoth()
|
||||
{
|
||||
// Arrange
|
||||
var jsonBody = JsonSerializer.Serialize(new { name = "Updated Item" }, _jsonOptions);
|
||||
var request = CreateRequest("PUT", "/api/items/item-456", body: jsonBody, contentType: "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("item-456", body.GetProperty("id").GetString());
|
||||
Assert.Equal("Updated Item", body.GetProperty("name").GetString());
|
||||
Assert.True(body.GetProperty("updated").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_Delete_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("DELETE", "/api/items/delete-me");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("delete-me", body.GetProperty("id").GetString());
|
||||
Assert.True(body.GetProperty("deleted").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_NotFound_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/nonexistent/path");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(404, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_WrongMethod_Returns404()
|
||||
{
|
||||
// Arrange - /api/health is GET only
|
||||
var request = CreateRequest("POST", "/api/health");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(404, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Identity Population Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_WithIdentityHeaders_PopulatesUser()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/api/me", headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Actor"] = "user-12345",
|
||||
["X-StellaOps-Tenant"] = "acme-corp"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
var body = DeserializeResponse(response);
|
||||
Assert.Equal("user-12345", body.GetProperty("userId").GetString());
|
||||
Assert.Equal("acme-corp", body.GetProperty("tenant").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Requests Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_ConcurrentRequests_AllSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var tasks = Enumerable.Range(1, 20)
|
||||
.Select(i => _dispatcher!.DispatchAsync(
|
||||
CreateRequest("GET", $"/api/items/concurrent-{i}"),
|
||||
CancellationToken.None));
|
||||
|
||||
// Act
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
Assert.All(responses, r => Assert.Equal(200, r.StatusCode));
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
var body = DeserializeResponse(responses[i]);
|
||||
Assert.Equal($"concurrent-{i + 1}", body.GetProperty("id").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Capture Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_ResponseContainsAllExpectedFields()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/api/health");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(request.RequestId, response.RequestId);
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
Assert.False(response.Payload.IsEmpty);
|
||||
Assert.False(response.HasMoreChunks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_ContentTypeHeader_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRequest("GET", "/api/items");
|
||||
|
||||
// Act
|
||||
var response = await _dispatcher!.DispatchAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
Assert.True(response.Headers.ContainsKey("Content-Type"));
|
||||
Assert.Contains("application/json", response.Headers["Content-Type"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private RequestFrame CreateRequest(
|
||||
string method,
|
||||
string path,
|
||||
string? body = null,
|
||||
string? contentType = null,
|
||||
Dictionary<string, string>? headers = null)
|
||||
{
|
||||
var requestHeaders = headers ?? new Dictionary<string, string>();
|
||||
|
||||
if (contentType is not null)
|
||||
{
|
||||
requestHeaders["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Headers = requestHeaders,
|
||||
Payload = body is not null ? Encoding.UTF8.GetBytes(body) : ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private JsonElement DeserializeResponse(ResponseFrame response)
|
||||
{
|
||||
if (response.Payload.IsEmpty)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<JsonElement>(response.Payload.Span, _jsonOptions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for validation of bridge options.
|
||||
/// </summary>
|
||||
public sealed class StellaRouterBridgeOptionsValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_MissingServiceName_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test";
|
||||
});
|
||||
});
|
||||
|
||||
Assert.Contains("ServiceName", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_MissingVersion_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Region = "test";
|
||||
});
|
||||
});
|
||||
|
||||
Assert.Contains("Version", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_MissingRegion_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Version = "1.0.0";
|
||||
});
|
||||
});
|
||||
|
||||
Assert.Contains("Region", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_ZeroTimeout_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test";
|
||||
options.DefaultTimeout = TimeSpan.Zero;
|
||||
});
|
||||
});
|
||||
|
||||
Assert.Contains("DefaultTimeout", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_ExcessiveTimeout_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test";
|
||||
options.DefaultTimeout = TimeSpan.FromMinutes(15);
|
||||
});
|
||||
});
|
||||
|
||||
Assert.Contains("DefaultTimeout", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStellaRouterBridge_ValidOptions_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
services.AddStellaRouterBridge(options =>
|
||||
{
|
||||
options.ServiceName = "test-service";
|
||||
options.Version = "1.0.0";
|
||||
options.Region = "test";
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider.GetService<StellaRouterBridgeOptions>());
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ public sealed class ConnectionManagerIntegrationTests
|
||||
// Act
|
||||
var connection = connectionManager.Connections.FirstOrDefault();
|
||||
|
||||
// Assert
|
||||
connection!.Endpoints.Should().HaveCount(8);
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
connection!.Endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -27,8 +27,8 @@ public sealed class EndpointRegistryIntegrationTests
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().HaveCount(8);
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -84,7 +84,7 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
});
|
||||
});
|
||||
|
||||
// Register test endpoint handlers
|
||||
// Register test endpoint handlers - basic endpoints
|
||||
builder.Services.AddScoped<EchoEndpoint>();
|
||||
builder.Services.AddScoped<GetUserEndpoint>();
|
||||
builder.Services.AddScoped<CreateUserEndpoint>();
|
||||
@@ -94,6 +94,17 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
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)
|
||||
@@ -193,8 +204,8 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat)
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -291,8 +302,8 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat)
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ namespace StellaOps.Router.Integration.Tests.Fixtures;
|
||||
public record EchoRequest(string Message);
|
||||
public record EchoResponse(string Echo, DateTime Timestamp);
|
||||
|
||||
public record GetUserRequest(string UserId);
|
||||
// Changed from positional record to property-based for path parameter binding support
|
||||
public record GetUserRequest
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
public record GetUserResponse(string UserId, string Name, string Email);
|
||||
|
||||
public record CreateUserRequest(string Name, string Email);
|
||||
@@ -115,10 +119,11 @@ public sealed class GetUserEndpoint : IStellaEndpoint<GetUserRequest, GetUserRes
|
||||
{
|
||||
public Task<GetUserResponse> HandleAsync(GetUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = request.UserId ?? "unknown";
|
||||
return Task.FromResult(new GetUserResponse(
|
||||
request.UserId,
|
||||
$"User-{request.UserId}",
|
||||
$"user-{request.UserId}@example.com"));
|
||||
userId,
|
||||
$"User-{userId}",
|
||||
$"user-{userId}@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +218,253 @@ public sealed class QuickEndpoint : IStellaEndpoint<EchoRequest, EchoResponse>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search endpoint demonstrating query parameter binding (FromQuery).
|
||||
/// GET /search?query=test&page=1&pageSize=20&includeDeleted=true
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/search")]
|
||||
public sealed class SearchEndpoint : IStellaEndpoint<SearchRequest, SearchResponse>
|
||||
{
|
||||
public Task<SearchResponse> HandleAsync(SearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SearchResponse(
|
||||
request.Query,
|
||||
request.Page,
|
||||
request.PageSize,
|
||||
request.IncludeDeleted,
|
||||
TotalResults: 42));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Item endpoint demonstrating path parameter binding (FromRoute).
|
||||
/// GET /categories/{categoryId}/items/{itemId}
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/categories/{categoryId}/items/{itemId}")]
|
||||
public sealed class GetItemEndpoint : IStellaEndpoint<GetItemRequest, GetItemResponse>
|
||||
{
|
||||
public Task<GetItemResponse> HandleAsync(GetItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new GetItemResponse(
|
||||
request.CategoryId,
|
||||
request.ItemId,
|
||||
Name: $"Item-{request.ItemId}-in-{request.CategoryId}",
|
||||
Price: 19.99m));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header inspection endpoint demonstrating header access (FromHeader).
|
||||
/// Uses raw endpoint to access all headers directly.
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/headers")]
|
||||
public sealed class HeaderTestEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var allHeaders = context.Headers.ToDictionary(
|
||||
h => h.Key,
|
||||
h => h.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var response = new HeaderTestResponse(
|
||||
Authorization: context.Headers.TryGetValue("Authorization", out var auth) ? auth : null,
|
||||
XRequestId: context.Headers.TryGetValue("X-Request-Id", out var reqId) ? reqId : null,
|
||||
XCustomHeader: context.Headers.TryGetValue("X-Custom-Header", out var custom) ? custom : null,
|
||||
AcceptLanguage: context.Headers.TryGetValue("Accept-Language", out var lang) ? lang : null,
|
||||
AllHeaders: allHeaders);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([new KeyValuePair<string, string>("Content-Type", "application/json")]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(json))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Form data endpoint demonstrating form binding (FromForm).
|
||||
/// POST /login with application/x-www-form-urlencoded body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("POST", "/login")]
|
||||
public sealed class LoginEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public async Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var contentType = context.Headers.TryGetValue("Content-Type", out var ct) ? ct : string.Empty;
|
||||
|
||||
// Parse form data
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
bool rememberMe = false;
|
||||
|
||||
if (contentType?.Contains("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
using var reader = new StreamReader(context.Body);
|
||||
var body = await reader.ReadToEndAsync(cancellationToken);
|
||||
var formData = ParseFormData(body);
|
||||
|
||||
username = formData.GetValueOrDefault("username");
|
||||
password = formData.GetValueOrDefault("password");
|
||||
if (formData.TryGetValue("rememberMe", out var rm))
|
||||
{
|
||||
rememberMe = string.Equals(rm, "true", StringComparison.OrdinalIgnoreCase) || rm == "1";
|
||||
}
|
||||
}
|
||||
|
||||
var response = new FormDataResponse(username, password, rememberMe, contentType);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([new KeyValuePair<string, string>("Content-Type", "application/json")]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(json))
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseFormData(string body)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(body)) return result;
|
||||
|
||||
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq < 0) continue;
|
||||
|
||||
var key = Uri.UnescapeDataString(pair[..eq].Replace('+', ' '));
|
||||
var value = Uri.UnescapeDataString(pair[(eq + 1)..].Replace('+', ' '));
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined binding endpoint demonstrating path + query + body binding.
|
||||
/// PUT /resources/{resourceId}?format=json&verbose=true with JSON body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("PUT", "/resources/{resourceId}")]
|
||||
public sealed class UpdateResourceEndpoint : IStellaEndpoint<CombinedRequest, CombinedResponse>
|
||||
{
|
||||
public Task<CombinedResponse> HandleAsync(CombinedRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new CombinedResponse(
|
||||
request.ResourceId,
|
||||
request.Format,
|
||||
request.Verbose,
|
||||
request.Name,
|
||||
request.Description));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pagination endpoint demonstrating optional query parameters with defaults.
|
||||
/// GET /items?offset=0&limit=10&sortBy=name&sortOrder=asc
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/items")]
|
||||
public sealed class ListItemsEndpoint : IStellaEndpoint<PagedRequest, PagedResponse>
|
||||
{
|
||||
public Task<PagedResponse> HandleAsync(PagedRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PagedResponse(
|
||||
Offset: request.Offset ?? 0,
|
||||
Limit: request.Limit ?? 20,
|
||||
SortBy: request.SortBy ?? "id",
|
||||
SortOrder: request.SortOrder ?? "asc"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw body echo endpoint for testing raw request body access.
|
||||
/// POST /raw-echo - echoes back whatever body is sent.
|
||||
/// </summary>
|
||||
[StellaEndpoint("POST", "/raw-echo")]
|
||||
public sealed class RawEchoEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public async Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(context.Body);
|
||||
var body = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var contentType = context.Headers.TryGetValue("Content-Type", out var ct) ? ct : "text/plain";
|
||||
|
||||
return new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([
|
||||
new KeyValuePair<string, string>("Content-Type", contentType ?? "text/plain"),
|
||||
new KeyValuePair<string, string>("X-Echo-Length", body.Length.ToString())
|
||||
]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(body))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE endpoint with path parameter.
|
||||
/// DELETE /items/{itemId}
|
||||
/// </summary>
|
||||
[StellaEndpoint("DELETE", "/items/{itemId}")]
|
||||
public sealed class DeleteItemEndpoint : IStellaEndpoint<DeleteItemRequest, DeleteItemResponse>
|
||||
{
|
||||
public Task<DeleteItemResponse> HandleAsync(DeleteItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new DeleteItemResponse(
|
||||
ItemId: request.ItemId,
|
||||
Deleted: true,
|
||||
DeletedAt: DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteItemRequest
|
||||
{
|
||||
public string? ItemId { get; set; }
|
||||
}
|
||||
|
||||
public record DeleteItemResponse(string? ItemId, bool Deleted, DateTime DeletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// PATCH endpoint for partial updates.
|
||||
/// PATCH /items/{itemId} with JSON body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("PATCH", "/items/{itemId}")]
|
||||
public sealed class PatchItemEndpoint : IStellaEndpoint<PatchItemRequest, PatchItemResponse>
|
||||
{
|
||||
public Task<PatchItemResponse> HandleAsync(PatchItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var updatedFields = new List<string>();
|
||||
if (request.Name is not null) updatedFields.Add("name");
|
||||
if (request.Price.HasValue) updatedFields.Add("price");
|
||||
|
||||
return Task.FromResult(new PatchItemResponse(
|
||||
ItemId: request.ItemId,
|
||||
Name: request.Name,
|
||||
Price: request.Price,
|
||||
UpdatedFields: updatedFields));
|
||||
}
|
||||
}
|
||||
|
||||
public record PatchItemRequest
|
||||
{
|
||||
public string? ItemId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
}
|
||||
|
||||
public record PatchItemResponse(string? ItemId, string? Name, decimal? Price, List<string> UpdatedFields);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Endpoint Discovery Provider
|
||||
@@ -226,6 +478,7 @@ public sealed class TestEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
{
|
||||
return
|
||||
[
|
||||
// Basic endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
@@ -303,6 +556,99 @@ public sealed class TestEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
Path = "/quick",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(5),
|
||||
HandlerType = typeof(QuickEndpoint)
|
||||
},
|
||||
|
||||
// Query parameter binding endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/search",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(SearchEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(ListItemsEndpoint)
|
||||
},
|
||||
|
||||
// Path parameter binding endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/categories/{categoryId}/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(GetItemEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "DELETE",
|
||||
Path = "/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(DeleteItemEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "PATCH",
|
||||
Path = "/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(PatchItemEndpoint)
|
||||
},
|
||||
|
||||
// Header binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/headers",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(HeaderTestEndpoint)
|
||||
},
|
||||
|
||||
// Form data binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/login",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(LoginEndpoint)
|
||||
},
|
||||
|
||||
// Combined binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "PUT",
|
||||
Path = "/resources/{resourceId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(UpdateResourceEndpoint)
|
||||
},
|
||||
|
||||
// Raw body endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/raw-echo",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(RawEchoEndpoint)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
namespace StellaOps.Router.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive tests for ASP.NET Minimal APIs-style parameter binding patterns.
|
||||
/// Tests FromQuery, FromRoute, FromHeader, FromBody, and FromForm binding across all HTTP methods.
|
||||
/// </summary>
|
||||
[Collection("Microservice Integration")]
|
||||
public sealed class ParameterBindingTests
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ParameterBindingTests(MicroserviceIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
#region FromQuery - Query Parameter Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_StringParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", "test-search-term")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("test-search-term");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_IntParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("page", 5)
|
||||
.WithQuery("pageSize", 25)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Page.Should().Be(5);
|
||||
result.PageSize.Should().Be(25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_BoolParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("includeDeleted", "true")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.IncludeDeleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_MultipleParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", "widgets")
|
||||
.WithQuery("page", 3)
|
||||
.WithQuery("pageSize", 50)
|
||||
.WithQuery("includeDeleted", "false")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("widgets");
|
||||
result.Page.Should().Be(3);
|
||||
result.PageSize.Should().Be(50);
|
||||
result.IncludeDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_UrlEncodedValues_BindCorrectly()
|
||||
{
|
||||
// Arrange - Query with special characters
|
||||
var query = "hello world & test=value";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", query)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be(query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_OptionalParameters_UseDefaults()
|
||||
{
|
||||
// Arrange & Act - No query parameters provided
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.SendAsync();
|
||||
|
||||
// Assert - Should use default values
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Offset.Should().Be(0); // Default
|
||||
result.Limit.Should().Be(20); // Default
|
||||
result.SortBy.Should().Be("id"); // Default
|
||||
result.SortOrder.Should().Be("asc"); // Default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_OverrideDefaults_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.WithQuery("offset", 100)
|
||||
.WithQuery("limit", 50)
|
||||
.WithQuery("sortBy", "name")
|
||||
.WithQuery("sortOrder", "desc")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Offset.Should().Be(100);
|
||||
result.Limit.Should().Be(50);
|
||||
result.SortBy.Should().Be("name");
|
||||
result.SortOrder.Should().Be("desc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_WithAnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act - Using anonymous object for multiple query params
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQueries(new { query = "bulk-search", page = 2, pageSize = 30 })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("bulk-search");
|
||||
result.Page.Should().Be(2);
|
||||
result.PageSize.Should().Be(30);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromRoute - Path Parameter Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_SinglePathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/user-123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("user-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_MultiplePathParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/categories/electronics/items/widget-456")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.CategoryId.Should().Be("electronics");
|
||||
result.ItemId.Should().Be("widget-456");
|
||||
result.Name.Should().Be("Item-widget-456-in-electronics");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_NumericPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/12345")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_GuidPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", $"/users/{guid}")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be(guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_SpecialCharactersInPath_BindsCorrectly()
|
||||
{
|
||||
// Arrange - URL-encoded special characters
|
||||
var categoryId = "cat-with-dash";
|
||||
var itemId = "item_underscore_123";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", $"/categories/{categoryId}/items/{itemId}")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.CategoryId.Should().Be(categoryId);
|
||||
result!.ItemId.Should().Be(itemId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromHeader - Header Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_AuthorizationHeader_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithAuthorization("Bearer", "test-token-12345")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Authorization.Should().Be("Bearer test-token-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_CustomHeaders_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithHeader("X-Request-Id", "req-abc-123")
|
||||
.WithHeader("X-Custom-Header", "custom-value")
|
||||
.WithHeader("Accept-Language", "en-US")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.XRequestId.Should().Be("req-abc-123");
|
||||
result!.XCustomHeader.Should().Be("custom-value");
|
||||
result!.AcceptLanguage.Should().Be("en-US");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_MultipleHeaders_AllAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer jwt-token",
|
||||
["X-Request-Id"] = "correlation-id-xyz",
|
||||
["X-Custom-Header"] = "value-123",
|
||||
["Accept-Language"] = "fr-FR"
|
||||
};
|
||||
|
||||
// Act
|
||||
var builder = _fixture.CreateRequest("GET", "/headers");
|
||||
foreach (var header in headers)
|
||||
{
|
||||
builder.WithHeader(header.Key, header.Value);
|
||||
}
|
||||
var response = await builder.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.AllHeaders.Should().ContainKey("Authorization");
|
||||
result.AllHeaders.Should().ContainKey("X-Request-Id");
|
||||
result.AllHeaders.Should().ContainKey("X-Custom-Header");
|
||||
result.AllHeaders.Should().ContainKey("Accept-Language");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_BearerToken_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithBearerToken("my-jwt-token-value")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Authorization.Should().Be("Bearer my-jwt-token-value");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromBody - JSON Body Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_SimpleJson_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest("Hello, World!"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Hello, World!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_ComplexObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateUserRequest("John Doe", "john@example.com");
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/users")
|
||||
.WithJsonBody(request)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CreateUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
result.UserId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_AnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new { Message = "Anonymous type test" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Anonymous type test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_NestedObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange - For raw echo we can test nested JSON structure
|
||||
var nested = new
|
||||
{
|
||||
level1 = new
|
||||
{
|
||||
level2 = new
|
||||
{
|
||||
value = "deeply nested"
|
||||
}
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(nested);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(Encoding.UTF8.GetBytes(json), "application/json")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Contain("deeply nested");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_CamelCaseNaming_BindsCorrectly()
|
||||
{
|
||||
// Arrange - Ensure camelCase property naming works
|
||||
var json = JsonSerializer.Serialize(new { message = "camelCase test" }, _jsonOptions);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(Encoding.UTF8.GetBytes(json), "application/json")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Contain("camelCase test");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromForm - Form Data Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_SimpleFormData_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "testuser")
|
||||
.WithFormField("password", "secret123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("testuser");
|
||||
result!.Password.Should().Be("secret123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_BooleanField_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "user")
|
||||
.WithFormField("password", "pass")
|
||||
.WithFormField("rememberMe", "true")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.RememberMe.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_WithAnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormFields(new { Username = "bulk-user", Password = "bulk-pass", RememberMe = "false" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("bulk-user");
|
||||
result!.Password.Should().Be("bulk-pass");
|
||||
result!.RememberMe.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_UrlEncodedSpecialChars_BindsCorrectly()
|
||||
{
|
||||
// Arrange - Special characters that need URL encoding
|
||||
var password = "p@ss=word&special!";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "test")
|
||||
.WithFormField("password", password)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Password.Should().Be(password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_ContentType_IsCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "test")
|
||||
.WithFormField("password", "test")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ContentType.Should().Contain("application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combined Binding - Multiple Sources
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - PUT /resources/{resourceId} with JSON body
|
||||
var body = new { Name = "Updated Resource", Description = "New description" };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/res-123")
|
||||
.WithJsonBody(body)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("res-123");
|
||||
result!.Name.Should().Be("Updated Resource");
|
||||
result!.Description.Should().Be("New description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathQueryAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - PUT /resources/{resourceId}?format=json&verbose=true with body
|
||||
var body = new { Name = "Full Update", Description = "Verbose mode" };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/res-456")
|
||||
.WithQuery("format", "json")
|
||||
.WithQuery("verbose", "true")
|
||||
.WithJsonBody(body)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("res-456");
|
||||
result!.Format.Should().Be("json");
|
||||
result!.Verbose.Should().BeTrue();
|
||||
result!.Name.Should().Be("Full Update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_HeadersAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - POST with headers and JSON body
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithHeader("X-Request-Id", "combo-test-123")
|
||||
.WithJsonBody(new EchoRequest("Combined header and body"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Combined header and body");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Methods
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGet_ReturnsData()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/get-test-user")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("get-test-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPost_CreatesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/users")
|
||||
.WithJsonBody(new CreateUserRequest("New User", "new@example.com"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CreateUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPut_UpdatesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/update-me")
|
||||
.WithJsonBody(new { Name = "Updated Name", Description = "Updated via PUT" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("update-me");
|
||||
result!.Name.Should().Be("Updated Name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_PartialUpdate()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("PATCH", "/items/patch-item-1")
|
||||
.WithJsonBody(new { Name = "Patched Name", Price = 29.99m })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PatchItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ItemId.Should().Be("patch-item-1");
|
||||
result!.Name.Should().Be("Patched Name");
|
||||
result!.Price.Should().Be(29.99m);
|
||||
result!.UpdatedFields.Should().Contain("name");
|
||||
result!.UpdatedFields.Should().Contain("price");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_PartialUpdate_OnlySpecifiedFields()
|
||||
{
|
||||
// Arrange & Act - Only update name, not price
|
||||
var response = await _fixture.CreateRequest("PATCH", "/items/partial-patch")
|
||||
.WithJsonBody(new { Name = "Only Name Updated" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PatchItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UpdatedFields.Should().Contain("name");
|
||||
result!.UpdatedFields.Should().NotContain("price");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpDelete_RemovesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("DELETE", "/items/delete-me-123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<DeleteItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ItemId.Should().Be("delete-me-123");
|
||||
result!.Deleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Raw Body Handling
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_PlainText_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var text = "This is plain text content";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithTextBody(text, "text/plain")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Be(text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_Xml_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var xml = "<root><element>value</element></root>";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithXmlBody(xml)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Be(xml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_Binary_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bytes = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(bytes, "application/octet-stream")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
// The raw echo endpoint reads as string, so binary data may be mangled
|
||||
// This test verifies the transport handles binary content
|
||||
response.Payload.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_ResponseHeaders_IncludeContentLength()
|
||||
{
|
||||
// Arrange
|
||||
var text = "Test content for length";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithTextBody(text)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Headers.Should().ContainKey("X-Echo-Length");
|
||||
response.Headers["X-Echo-Length"].Should().Be(text.Length.ToString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyBody_HandledCorrectly()
|
||||
{
|
||||
// Arrange & Act - GET with no body should work for endpoints with optional params
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
// Should use default values when no query params provided
|
||||
result!.Offset.Should().Be(0);
|
||||
result.Limit.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyQueryString_UsesDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
// Should use default values from the endpoint
|
||||
result!.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRequests_HandleCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var tasks = Enumerable.Range(1, 10)
|
||||
.Select(i => _fixture.CreateRequest("GET", $"/users/concurrent-user-{i}")
|
||||
.SendAsync());
|
||||
|
||||
// Act
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
responses.Should().HaveCount(10);
|
||||
responses.Should().OnlyContain(r => r.StatusCode == 200);
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(responses[i]);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be($"concurrent-user-{i + 1}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LargePayload_HandledCorrectly()
|
||||
{
|
||||
// Arrange - Create a moderately large message
|
||||
var largeMessage = new string('x', 10000);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest(largeMessage))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain(largeMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnicodeContent_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var unicodeMessage = "Hello 世界! Привет мир! 🎉 مرحبا";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest(unicodeMessage))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain(unicodeMessage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user