Add unit tests and logging infrastructure for InMemory and RabbitMQ transports
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented RecordingLogger and RecordingLoggerFactory for capturing log entries in tests.
- Added unit tests for InMemoryChannel, covering constructor behavior, property assignments, channel communication, and disposal.
- Created InMemoryTransportOptionsTests to validate default values and customizable options for InMemory transport.
- Developed RabbitMqFrameProtocolTests to ensure correct parsing and property creation for RabbitMQ frames.
- Added RabbitMqTransportOptionsTests to verify default settings and customization options for RabbitMQ transport.
- Updated project files for testing libraries and dependencies.
This commit is contained in:
StellaOps Bot
2025-12-05 09:38:45 +02:00
parent 6a299d231f
commit 53508ceccb
98 changed files with 10868 additions and 663 deletions

View File

@@ -0,0 +1,376 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="EndpointRegistry"/>.
/// </summary>
public sealed class EndpointRegistryTests
{
private static EndpointDescriptor CreateEndpoint(string method, string path)
{
return new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = method,
Path = path
};
}
#region Register Tests
[Fact]
public void Register_SingleEndpoint_AddsToRegistry()
{
// Arrange
var registry = new EndpointRegistry();
var endpoint = CreateEndpoint("GET", "/api/users");
// Act
registry.Register(endpoint);
// Assert
registry.GetAllEndpoints().Should().HaveCount(1);
registry.GetAllEndpoints()[0].Should().Be(endpoint);
}
[Fact]
public void Register_MultipleEndpoints_AddsAllToRegistry()
{
// Arrange
var registry = new EndpointRegistry();
// Act
registry.Register(CreateEndpoint("GET", "/api/users"));
registry.Register(CreateEndpoint("POST", "/api/users"));
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
// Assert
registry.GetAllEndpoints().Should().HaveCount(3);
}
[Fact]
public void RegisterAll_AddsAllEndpoints()
{
// Arrange
var registry = new EndpointRegistry();
var endpoints = new[]
{
CreateEndpoint("GET", "/api/users"),
CreateEndpoint("POST", "/api/users"),
CreateEndpoint("DELETE", "/api/users/{id}")
};
// Act
registry.RegisterAll(endpoints);
// Assert
registry.GetAllEndpoints().Should().HaveCount(3);
}
[Fact]
public void RegisterAll_WithEmptyCollection_DoesNotAddAny()
{
// Arrange
var registry = new EndpointRegistry();
// Act
registry.RegisterAll([]);
// Assert
registry.GetAllEndpoints().Should().BeEmpty();
}
#endregion
#region TryMatch Method Tests
[Fact]
public void TryMatch_ExactMethodAndPath_ReturnsTrue()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act
var result = registry.TryMatch("GET", "/api/users", out var match);
// Assert
result.Should().BeTrue();
match.Should().NotBeNull();
match!.Endpoint.Path.Should().Be("/api/users");
}
[Fact]
public void TryMatch_NonMatchingMethod_ReturnsFalse()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act
var result = registry.TryMatch("POST", "/api/users", out var match);
// Assert
result.Should().BeFalse();
match.Should().BeNull();
}
[Fact]
public void TryMatch_NonMatchingPath_ReturnsFalse()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act
var result = registry.TryMatch("GET", "/api/items", out var match);
// Assert
result.Should().BeFalse();
match.Should().BeNull();
}
[Fact]
public void TryMatch_MethodIsCaseInsensitive()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act & Assert
registry.TryMatch("get", "/api/users", out _).Should().BeTrue();
registry.TryMatch("Get", "/api/users", out _).Should().BeTrue();
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
}
[Fact]
public void TryMatch_PathIsCaseInsensitive_WhenEnabled()
{
// Arrange
var registry = new EndpointRegistry(caseInsensitive: true);
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act & Assert
registry.TryMatch("GET", "/API/USERS", out _).Should().BeTrue();
registry.TryMatch("GET", "/Api/Users", out _).Should().BeTrue();
}
[Fact]
public void TryMatch_PathIsCaseSensitive_WhenDisabled()
{
// Arrange
var registry = new EndpointRegistry(caseInsensitive: false);
registry.Register(CreateEndpoint("GET", "/api/users"));
// Act & Assert
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
registry.TryMatch("GET", "/API/USERS", out _).Should().BeFalse();
}
#endregion
#region TryMatch Path Parameter Tests
[Fact]
public void TryMatch_PathWithParameter_ExtractsParameter()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
// Act
var result = registry.TryMatch("GET", "/api/users/123", out var match);
// Assert
result.Should().BeTrue();
match.Should().NotBeNull();
match!.PathParameters.Should().ContainKey("id");
match.PathParameters["id"].Should().Be("123");
}
[Fact]
public void TryMatch_PathWithMultipleParameters_ExtractsAll()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users/{userId}/orders/{orderId}"));
// Act
var result = registry.TryMatch("GET", "/api/users/456/orders/789", out var match);
// Assert
result.Should().BeTrue();
match.Should().NotBeNull();
match!.PathParameters.Should().HaveCount(2);
match.PathParameters["userId"].Should().Be("456");
match.PathParameters["orderId"].Should().Be("789");
}
[Fact]
public void TryMatch_PathParameterWithSpecialChars_ExtractsParameter()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/items/{itemId}"));
// Act
var result = registry.TryMatch("GET", "/api/items/item-with-dashes", out var match);
// Assert
result.Should().BeTrue();
match!.PathParameters["itemId"].Should().Be("item-with-dashes");
}
[Fact]
public void TryMatch_EmptyPathParameter_DoesNotMatch()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
// Act
var result = registry.TryMatch("GET", "/api/users/", out var match);
// Assert
result.Should().BeFalse();
}
#endregion
#region TryMatch Multiple Endpoints Tests
[Fact]
public void TryMatch_FirstMatchingEndpoint_ReturnsFirst()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
registry.Register(CreateEndpoint("GET", "/api/users")); // duplicate
// Act
registry.TryMatch("GET", "/api/users", out var match);
// Assert - should return the first registered
match.Should().NotBeNull();
}
[Fact]
public void TryMatch_SelectsCorrectEndpointByMethod()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(new EndpointDescriptor
{
ServiceName = "test",
Version = "1.0",
Method = "GET",
Path = "/api/users",
DefaultTimeout = TimeSpan.FromSeconds(10)
});
registry.Register(new EndpointDescriptor
{
ServiceName = "test",
Version = "1.0",
Method = "POST",
Path = "/api/users",
DefaultTimeout = TimeSpan.FromSeconds(30)
});
// Act
registry.TryMatch("POST", "/api/users", out var match);
// Assert
match.Should().NotBeNull();
match!.Endpoint.Method.Should().Be("POST");
match.Endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
#endregion
#region GetAllEndpoints Tests
[Fact]
public void GetAllEndpoints_EmptyRegistry_ReturnsEmptyList()
{
// Arrange
var registry = new EndpointRegistry();
// Act
var endpoints = registry.GetAllEndpoints();
// Assert
endpoints.Should().BeEmpty();
}
[Fact]
public void GetAllEndpoints_ReturnsAllRegisteredEndpoints()
{
// Arrange
var registry = new EndpointRegistry();
var endpoint1 = CreateEndpoint("GET", "/api/a");
var endpoint2 = CreateEndpoint("POST", "/api/b");
var endpoint3 = CreateEndpoint("DELETE", "/api/c");
registry.RegisterAll([endpoint1, endpoint2, endpoint3]);
// Act
var endpoints = registry.GetAllEndpoints();
// Assert
endpoints.Should().HaveCount(3);
endpoints.Should().Contain(endpoint1);
endpoints.Should().Contain(endpoint2);
endpoints.Should().Contain(endpoint3);
}
[Fact]
public void GetAllEndpoints_PreservesRegistrationOrder()
{
// Arrange
var registry = new EndpointRegistry();
var endpoint1 = CreateEndpoint("GET", "/first");
var endpoint2 = CreateEndpoint("GET", "/second");
var endpoint3 = CreateEndpoint("GET", "/third");
registry.Register(endpoint1);
registry.Register(endpoint2);
registry.Register(endpoint3);
// Act
var endpoints = registry.GetAllEndpoints();
// Assert
endpoints[0].Should().Be(endpoint1);
endpoints[1].Should().Be(endpoint2);
endpoints[2].Should().Be(endpoint3);
}
#endregion
#region Constructor Tests
[Fact]
public void Constructor_DefaultCaseInsensitive_IsTrue()
{
// Arrange
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/Test"));
// Act & Assert - should match case-insensitively by default
registry.TryMatch("GET", "/api/test", out _).Should().BeTrue();
}
[Fact]
public void Constructor_ExplicitCaseInsensitiveFalse_IsCaseSensitive()
{
// Arrange
var registry = new EndpointRegistry(caseInsensitive: false);
registry.Register(CreateEndpoint("GET", "/api/Test"));
// Act & Assert
registry.TryMatch("GET", "/api/Test", out _).Should().BeTrue();
registry.TryMatch("GET", "/api/test", out _).Should().BeFalse();
}
#endregion
}

View File

@@ -0,0 +1,383 @@
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="HeaderCollection"/>.
/// </summary>
public sealed class HeaderCollectionTests
{
#region Constructor Tests
[Fact]
public void Constructor_Default_CreatesEmptyCollection()
{
// Arrange & Act
var headers = new HeaderCollection();
// Assert
headers.Should().BeEmpty();
}
[Fact]
public void Constructor_WithKeyValuePairs_AddsAllHeaders()
{
// Arrange
var pairs = new[]
{
new KeyValuePair<string, string>("Content-Type", "application/json"),
new KeyValuePair<string, string>("Accept", "application/json")
};
// Act
var headers = new HeaderCollection(pairs);
// Assert
headers["Content-Type"].Should().Be("application/json");
headers["Accept"].Should().Be("application/json");
}
[Fact]
public void Constructor_WithDuplicateKeys_AddsMultipleValues()
{
// Arrange
var pairs = new[]
{
new KeyValuePair<string, string>("Accept", "application/json"),
new KeyValuePair<string, string>("Accept", "text/plain")
};
// Act
var headers = new HeaderCollection(pairs);
// Assert
headers.GetValues("Accept").Should().BeEquivalentTo(["application/json", "text/plain"]);
}
#endregion
#region Empty Tests
[Fact]
public void Empty_IsSharedInstance()
{
// Arrange & Act
var empty1 = HeaderCollection.Empty;
var empty2 = HeaderCollection.Empty;
// Assert
empty1.Should().BeSameAs(empty2);
}
[Fact]
public void Empty_HasNoHeaders()
{
// Arrange & Act
var empty = HeaderCollection.Empty;
// Assert
empty.Should().BeEmpty();
}
#endregion
#region Indexer Tests
[Fact]
public void Indexer_ExistingKey_ReturnsFirstValue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act
var value = headers["Content-Type"];
// Assert
value.Should().Be("application/json");
}
[Fact]
public void Indexer_MultipleValues_ReturnsFirstValue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Accept", "application/json");
headers.Add("Accept", "text/plain");
// Act
var value = headers["Accept"];
// Assert
value.Should().Be("application/json");
}
[Fact]
public void Indexer_NonexistentKey_ReturnsNull()
{
// Arrange
var headers = new HeaderCollection();
// Act
var value = headers["X-Missing"];
// Assert
value.Should().BeNull();
}
[Fact]
public void Indexer_IsCaseInsensitive()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act & Assert
headers["content-type"].Should().Be("application/json");
headers["CONTENT-TYPE"].Should().Be("application/json");
headers["Content-TYPE"].Should().Be("application/json");
}
#endregion
#region Add Tests
[Fact]
public void Add_NewKey_AddsHeader()
{
// Arrange
var headers = new HeaderCollection();
// Act
headers.Add("Content-Type", "application/json");
// Assert
headers["Content-Type"].Should().Be("application/json");
}
[Fact]
public void Add_ExistingKey_AppendsValue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Accept", "application/json");
// Act
headers.Add("Accept", "text/plain");
// Assert
headers.GetValues("Accept").Should().HaveCount(2);
}
[Fact]
public void Add_CaseInsensitiveKey_AppendsToExisting()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act
headers.Add("content-type", "text/plain");
// Assert
headers.GetValues("Content-Type").Should().HaveCount(2);
}
#endregion
#region Set Tests
[Fact]
public void Set_NewKey_AddsHeader()
{
// Arrange
var headers = new HeaderCollection();
// Act
headers.Set("Content-Type", "application/json");
// Assert
headers["Content-Type"].Should().Be("application/json");
}
[Fact]
public void Set_ExistingKey_ReplacesValue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "text/plain");
headers.Add("Content-Type", "text/html");
// Act
headers.Set("Content-Type", "application/json");
// Assert
headers.GetValues("Content-Type").Should().BeEquivalentTo(["application/json"]);
}
#endregion
#region GetValues Tests
[Fact]
public void GetValues_ExistingKey_ReturnsAllValues()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Accept", "application/json");
headers.Add("Accept", "text/plain");
headers.Add("Accept", "text/html");
// Act
var values = headers.GetValues("Accept");
// Assert
values.Should().BeEquivalentTo(["application/json", "text/plain", "text/html"]);
}
[Fact]
public void GetValues_NonexistentKey_ReturnsEmptyEnumerable()
{
// Arrange
var headers = new HeaderCollection();
// Act
var values = headers.GetValues("X-Missing");
// Assert
values.Should().BeEmpty();
}
[Fact]
public void GetValues_IsCaseInsensitive()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Accept", "application/json");
// Act & Assert
headers.GetValues("accept").Should().Contain("application/json");
headers.GetValues("ACCEPT").Should().Contain("application/json");
}
#endregion
#region TryGetValue Tests
[Fact]
public void TryGetValue_ExistingKey_ReturnsTrueAndValue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act
var result = headers.TryGetValue("Content-Type", out var value);
// Assert
result.Should().BeTrue();
value.Should().Be("application/json");
}
[Fact]
public void TryGetValue_NonexistentKey_ReturnsFalse()
{
// Arrange
var headers = new HeaderCollection();
// Act
var result = headers.TryGetValue("X-Missing", out var value);
// Assert
result.Should().BeFalse();
value.Should().BeNull();
}
[Fact]
public void TryGetValue_IsCaseInsensitive()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act
var result = headers.TryGetValue("content-type", out var value);
// Assert
result.Should().BeTrue();
value.Should().Be("application/json");
}
#endregion
#region ContainsKey Tests
[Fact]
public void ContainsKey_ExistingKey_ReturnsTrue()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act & Assert
headers.ContainsKey("Content-Type").Should().BeTrue();
}
[Fact]
public void ContainsKey_NonexistentKey_ReturnsFalse()
{
// Arrange
var headers = new HeaderCollection();
// Act & Assert
headers.ContainsKey("X-Missing").Should().BeFalse();
}
[Fact]
public void ContainsKey_IsCaseInsensitive()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
// Act & Assert
headers.ContainsKey("content-type").Should().BeTrue();
headers.ContainsKey("CONTENT-TYPE").Should().BeTrue();
}
#endregion
#region Enumeration Tests
[Fact]
public void GetEnumerator_EnumeratesAllHeaderValues()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
headers.Add("Accept", "text/plain");
headers.Add("Accept", "text/html");
// Act
var list = headers.ToList();
// Assert
list.Should().HaveCount(3);
list.Should().Contain(new KeyValuePair<string, string>("Content-Type", "application/json"));
list.Should().Contain(new KeyValuePair<string, string>("Accept", "text/plain"));
list.Should().Contain(new KeyValuePair<string, string>("Accept", "text/html"));
}
[Fact]
public void GetEnumerator_EmptyCollection_EnumeratesNothing()
{
// Arrange
var headers = new HeaderCollection();
// Act
var list = headers.ToList();
// Assert
list.Should().BeEmpty();
}
#endregion
}

View File

@@ -0,0 +1,309 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="InflightRequestTracker"/>.
/// </summary>
public sealed class InflightRequestTrackerTests : IDisposable
{
private readonly InflightRequestTracker _tracker;
public InflightRequestTrackerTests()
{
_tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
}
public void Dispose()
{
_tracker.Dispose();
}
#region Track Tests
[Fact]
public void Track_NewRequest_ReturnsNonCancelledToken()
{
// Arrange
var correlationId = Guid.NewGuid();
// Act
var token = _tracker.Track(correlationId);
// Assert
token.IsCancellationRequested.Should().BeFalse();
}
[Fact]
public void Track_NewRequest_IncreasesCount()
{
// Arrange
var correlationId = Guid.NewGuid();
// Act
_tracker.Track(correlationId);
// Assert
_tracker.Count.Should().Be(1);
}
[Fact]
public void Track_MultipleRequests_TracksAll()
{
// Arrange & Act
_tracker.Track(Guid.NewGuid());
_tracker.Track(Guid.NewGuid());
_tracker.Track(Guid.NewGuid());
// Assert
_tracker.Count.Should().Be(3);
}
[Fact]
public void Track_DuplicateCorrelationId_ThrowsInvalidOperationException()
{
// Arrange
var correlationId = Guid.NewGuid();
_tracker.Track(correlationId);
// Act
var action = () => _tracker.Track(correlationId);
// Assert
action.Should().Throw<InvalidOperationException>()
.WithMessage($"*{correlationId}*already being tracked*");
}
[Fact]
public void Track_AfterDispose_ThrowsObjectDisposedException()
{
// Arrange
_tracker.Dispose();
// Act
var action = () => _tracker.Track(Guid.NewGuid());
// Assert
action.Should().Throw<ObjectDisposedException>();
}
#endregion
#region Cancel Tests
[Fact]
public void Cancel_TrackedRequest_CancelsToken()
{
// Arrange
var correlationId = Guid.NewGuid();
var token = _tracker.Track(correlationId);
// Act
var result = _tracker.Cancel(correlationId, "Test cancellation");
// Assert
result.Should().BeTrue();
token.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void Cancel_UntrackedRequest_ReturnsFalse()
{
// Arrange
var correlationId = Guid.NewGuid();
// Act
var result = _tracker.Cancel(correlationId, "Test cancellation");
// Assert
result.Should().BeFalse();
}
[Fact]
public void Cancel_WithNullReason_Works()
{
// Arrange
var correlationId = Guid.NewGuid();
_tracker.Track(correlationId);
// Act
var result = _tracker.Cancel(correlationId, null);
// Assert
result.Should().BeTrue();
}
[Fact]
public void Cancel_CompletedRequest_ReturnsFalse()
{
// Arrange
var correlationId = Guid.NewGuid();
_tracker.Track(correlationId);
_tracker.Complete(correlationId);
// Act
var result = _tracker.Cancel(correlationId, "Test cancellation");
// Assert
result.Should().BeFalse();
}
#endregion
#region Complete Tests
[Fact]
public void Complete_TrackedRequest_RemovesFromTracking()
{
// Arrange
var correlationId = Guid.NewGuid();
_tracker.Track(correlationId);
// Act
_tracker.Complete(correlationId);
// Assert
_tracker.Count.Should().Be(0);
}
[Fact]
public void Complete_UntrackedRequest_DoesNotThrow()
{
// Arrange
var correlationId = Guid.NewGuid();
// Act
var action = () => _tracker.Complete(correlationId);
// Assert
action.Should().NotThrow();
}
[Fact]
public void Complete_MultipleCompletions_DoesNotThrow()
{
// Arrange
var correlationId = Guid.NewGuid();
_tracker.Track(correlationId);
// Act
var action = () =>
{
_tracker.Complete(correlationId);
_tracker.Complete(correlationId);
};
// Assert
action.Should().NotThrow();
}
#endregion
#region CancelAll Tests
[Fact]
public void CancelAll_CancelsAllTrackedRequests()
{
// Arrange
var token1 = _tracker.Track(Guid.NewGuid());
var token2 = _tracker.Track(Guid.NewGuid());
var token3 = _tracker.Track(Guid.NewGuid());
// Act
_tracker.CancelAll("Shutdown");
// Assert
token1.IsCancellationRequested.Should().BeTrue();
token2.IsCancellationRequested.Should().BeTrue();
token3.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void CancelAll_ClearsTrackedRequests()
{
// Arrange
_tracker.Track(Guid.NewGuid());
_tracker.Track(Guid.NewGuid());
// Act
_tracker.CancelAll("Shutdown");
// Assert
_tracker.Count.Should().Be(0);
}
[Fact]
public void CancelAll_WithNoRequests_DoesNotThrow()
{
// Arrange & Act
var action = () => _tracker.CancelAll("Test");
// Assert
action.Should().NotThrow();
}
#endregion
#region Dispose Tests
[Fact]
public void Dispose_CancelsAllRequests()
{
// Arrange
var token = _tracker.Track(Guid.NewGuid());
// Act
_tracker.Dispose();
// Assert
token.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange & Act
var action = () =>
{
_tracker.Dispose();
_tracker.Dispose();
_tracker.Dispose();
};
// Assert
action.Should().NotThrow();
}
#endregion
#region Count Tests
[Fact]
public void Count_InitiallyZero()
{
// Arrange - use a fresh tracker
using var tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
// Assert
tracker.Count.Should().Be(0);
}
[Fact]
public void Count_ReflectsActiveRequests()
{
// Arrange
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
// Act
_tracker.Track(id1);
_tracker.Track(id2);
_tracker.Complete(id1);
// Assert
_tracker.Count.Should().Be(1);
}
#endregion
}

View File

@@ -0,0 +1,272 @@
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="RawRequestContext"/>.
/// </summary>
public sealed class RawRequestContextTests
{
#region Default Values Tests
[Fact]
public void Constructor_Method_DefaultsToEmptyString()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.Method.Should().BeEmpty();
}
[Fact]
public void Constructor_Path_DefaultsToEmptyString()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.Path.Should().BeEmpty();
}
[Fact]
public void Constructor_PathParameters_DefaultsToEmptyDictionary()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.PathParameters.Should().NotBeNull();
context.PathParameters.Should().BeEmpty();
}
[Fact]
public void Constructor_Headers_DefaultsToEmptyCollection()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.Headers.Should().BeSameAs(HeaderCollection.Empty);
}
[Fact]
public void Constructor_Body_DefaultsToStreamNull()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.Body.Should().BeSameAs(Stream.Null);
}
[Fact]
public void Constructor_CancellationToken_DefaultsToNone()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.CancellationToken.Should().Be(CancellationToken.None);
}
[Fact]
public void Constructor_CorrelationId_DefaultsToNull()
{
// Arrange & Act
var context = new RawRequestContext();
// Assert
context.CorrelationId.Should().BeNull();
}
#endregion
#region Property Initialization Tests
[Fact]
public void Method_CanBeInitialized()
{
// Arrange & Act
var context = new RawRequestContext { Method = "POST" };
// Assert
context.Method.Should().Be("POST");
}
[Fact]
public void Path_CanBeInitialized()
{
// Arrange & Act
var context = new RawRequestContext { Path = "/api/users/123" };
// Assert
context.Path.Should().Be("/api/users/123");
}
[Fact]
public void PathParameters_CanBeInitialized()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["id"] = "123",
["action"] = "update"
};
// Act
var context = new RawRequestContext { PathParameters = parameters };
// Assert
context.PathParameters.Should().HaveCount(2);
context.PathParameters["id"].Should().Be("123");
context.PathParameters["action"].Should().Be("update");
}
[Fact]
public void Headers_CanBeInitialized()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
headers.Add("Authorization", "Bearer token");
// Act
var context = new RawRequestContext { Headers = headers };
// Assert
context.Headers["Content-Type"].Should().Be("application/json");
context.Headers["Authorization"].Should().Be("Bearer token");
}
[Fact]
public void Body_CanBeInitialized()
{
// Arrange
var body = new MemoryStream([1, 2, 3, 4, 5]);
// Act
var context = new RawRequestContext { Body = body };
// Assert
context.Body.Should().BeSameAs(body);
}
[Fact]
public void CancellationToken_CanBeInitialized()
{
// Arrange
using var cts = new CancellationTokenSource();
// Act
var context = new RawRequestContext { CancellationToken = cts.Token };
// Assert
context.CancellationToken.Should().Be(cts.Token);
}
[Fact]
public void CorrelationId_CanBeInitialized()
{
// Arrange & Act
var context = new RawRequestContext { CorrelationId = "req-12345" };
// Assert
context.CorrelationId.Should().Be("req-12345");
}
#endregion
#region Complete Context Tests
[Fact]
public void CompleteContext_AllPropertiesSet_Works()
{
// Arrange
var headers = new HeaderCollection();
headers.Add("Content-Type", "application/json");
var body = new MemoryStream([123, 125]); // "{}"
using var cts = new CancellationTokenSource();
// Act
var context = new RawRequestContext
{
Method = "POST",
Path = "/api/users/{id}",
PathParameters = new Dictionary<string, string> { ["id"] = "456" },
Headers = headers,
Body = body,
CancellationToken = cts.Token,
CorrelationId = "corr-789"
};
// Assert
context.Method.Should().Be("POST");
context.Path.Should().Be("/api/users/{id}");
context.PathParameters["id"].Should().Be("456");
context.Headers["Content-Type"].Should().Be("application/json");
context.Body.Should().BeSameAs(body);
context.CancellationToken.Should().Be(cts.Token);
context.CorrelationId.Should().Be("corr-789");
}
[Fact]
public void Context_WithCancelledToken_HasCancellationRequested()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act
var context = new RawRequestContext { CancellationToken = cts.Token };
// Assert
context.CancellationToken.IsCancellationRequested.Should().BeTrue();
}
#endregion
#region Typical Use Case Tests
[Fact]
public void TypicalGetRequest_HasMinimalProperties()
{
// Arrange & Act
var context = new RawRequestContext
{
Method = "GET",
Path = "/api/health"
};
// Assert
context.Method.Should().Be("GET");
context.Path.Should().Be("/api/health");
context.Body.Should().BeSameAs(Stream.Null);
context.Headers.Should().BeEmpty();
}
[Fact]
public void TypicalPostRequest_HasBodyAndHeaders()
{
// Arrange
var headers = new HeaderCollection();
headers.Set("Content-Type", "application/json");
var body = new MemoryStream([123, 34, 110, 97, 109, 101, 34, 58, 34, 116, 101, 115, 116, 34, 125]); // {"name":"test"}
// Act
var context = new RawRequestContext
{
Method = "POST",
Path = "/api/users",
Headers = headers,
Body = body
};
// Assert
context.Method.Should().Be("POST");
context.Headers["Content-Type"].Should().Be("application/json");
context.Body.Length.Should().BeGreaterThan(0);
}
#endregion
}

View File

@@ -0,0 +1,354 @@
using System.Text;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="RawResponse"/>.
/// </summary>
public sealed class RawResponseTests
{
#region Default Values Tests
[Fact]
public void Constructor_StatusCode_DefaultsTo200()
{
// Arrange & Act
var response = new RawResponse();
// Assert
response.StatusCode.Should().Be(200);
}
[Fact]
public void Constructor_Headers_DefaultsToEmpty()
{
// Arrange & Act
var response = new RawResponse();
// Assert
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
}
[Fact]
public void Constructor_Body_DefaultsToStreamNull()
{
// Arrange & Act
var response = new RawResponse();
// Assert
response.Body.Should().BeSameAs(Stream.Null);
}
#endregion
#region Ok Factory Method Tests
[Fact]
public void Ok_WithStream_CreatesOkResponse()
{
// Arrange
var stream = new MemoryStream([1, 2, 3, 4, 5]);
// Act
var response = RawResponse.Ok(stream);
// Assert
response.StatusCode.Should().Be(200);
response.Body.Should().BeSameAs(stream);
}
[Fact]
public void Ok_WithByteArray_CreatesOkResponse()
{
// Arrange
var data = new byte[] { 1, 2, 3, 4, 5 };
// Act
var response = RawResponse.Ok(data);
// Assert
response.StatusCode.Should().Be(200);
response.Body.Should().BeOfType<MemoryStream>();
((MemoryStream)response.Body).ToArray().Should().BeEquivalentTo(data);
}
[Fact]
public void Ok_WithString_CreatesOkResponse()
{
// Arrange
var text = "Hello, World!";
// Act
var response = RawResponse.Ok(text);
// Assert
response.StatusCode.Should().Be(200);
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be(text);
}
[Fact]
public void Ok_WithEmptyString_CreatesOkResponse()
{
// Arrange & Act
var response = RawResponse.Ok("");
// Assert
response.StatusCode.Should().Be(200);
response.Body.Length.Should().Be(0);
}
#endregion
#region NoContent Factory Method Tests
[Fact]
public void NoContent_Creates204Response()
{
// Arrange & Act
var response = RawResponse.NoContent();
// Assert
response.StatusCode.Should().Be(204);
}
[Fact]
public void NoContent_HasDefaultHeaders()
{
// Arrange & Act
var response = RawResponse.NoContent();
// Assert
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
}
[Fact]
public void NoContent_HasDefaultBody()
{
// Arrange & Act
var response = RawResponse.NoContent();
// Assert
response.Body.Should().BeSameAs(Stream.Null);
}
#endregion
#region BadRequest Factory Method Tests
[Fact]
public void BadRequest_Creates400Response()
{
// Arrange & Act
var response = RawResponse.BadRequest();
// Assert
response.StatusCode.Should().Be(400);
}
[Fact]
public void BadRequest_WithDefaultMessage_HasBadRequestText()
{
// Arrange & Act
var response = RawResponse.BadRequest();
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Bad Request");
}
[Fact]
public void BadRequest_WithCustomMessage_HasCustomText()
{
// Arrange & Act
var response = RawResponse.BadRequest("Invalid input");
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Invalid input");
}
[Fact]
public void BadRequest_SetsTextPlainContentType()
{
// Arrange & Act
var response = RawResponse.BadRequest();
// Assert
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
}
#endregion
#region NotFound Factory Method Tests
[Fact]
public void NotFound_Creates404Response()
{
// Arrange & Act
var response = RawResponse.NotFound();
// Assert
response.StatusCode.Should().Be(404);
}
[Fact]
public void NotFound_WithDefaultMessage_HasNotFoundText()
{
// Arrange & Act
var response = RawResponse.NotFound();
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Not Found");
}
[Fact]
public void NotFound_WithCustomMessage_HasCustomText()
{
// Arrange & Act
var response = RawResponse.NotFound("Resource does not exist");
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Resource does not exist");
}
#endregion
#region InternalError Factory Method Tests
[Fact]
public void InternalError_Creates500Response()
{
// Arrange & Act
var response = RawResponse.InternalError();
// Assert
response.StatusCode.Should().Be(500);
}
[Fact]
public void InternalError_WithDefaultMessage_HasInternalServerErrorText()
{
// Arrange & Act
var response = RawResponse.InternalError();
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Internal Server Error");
}
[Fact]
public void InternalError_WithCustomMessage_HasCustomText()
{
// Arrange & Act
var response = RawResponse.InternalError("Database connection failed");
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be("Database connection failed");
}
#endregion
#region Error Factory Method Tests
[Theory]
[InlineData(400, "Bad Request")]
[InlineData(401, "Unauthorized")]
[InlineData(403, "Forbidden")]
[InlineData(404, "Not Found")]
[InlineData(500, "Internal Server Error")]
[InlineData(502, "Bad Gateway")]
[InlineData(503, "Service Unavailable")]
public void Error_CreatesResponseWithCorrectStatusCode(int statusCode, string message)
{
// Arrange & Act
var response = RawResponse.Error(statusCode, message);
// Assert
response.StatusCode.Should().Be(statusCode);
}
[Fact]
public void Error_SetsCorrectContentType()
{
// Arrange & Act
var response = RawResponse.Error(418, "I'm a teapot");
// Assert
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
}
[Fact]
public void Error_SetsMessageInBody()
{
// Arrange
var message = "Custom error message";
// Act
var response = RawResponse.Error(400, message);
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be(message);
}
[Fact]
public void Error_WithUnicodeMessage_EncodesCorrectly()
{
// Arrange
var message = "Error: \u4e2d\u6587\u6d88\u606f";
// Act
var response = RawResponse.Error(400, message);
// Assert
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be(message);
}
#endregion
#region Property Initialization Tests
[Fact]
public void StatusCode_CanBeInitialized()
{
// Arrange & Act
var response = new RawResponse { StatusCode = 201 };
// Assert
response.StatusCode.Should().Be(201);
}
[Fact]
public void Headers_CanBeInitialized()
{
// Arrange
var headers = new HeaderCollection();
headers.Set("X-Custom", "value");
// Act
var response = new RawResponse { Headers = headers };
// Assert
response.Headers["X-Custom"].Should().Be("value");
}
[Fact]
public void Body_CanBeInitialized()
{
// Arrange
var stream = new MemoryStream([1, 2, 3]);
// Act
var response = new RawResponse { Body = stream };
// Assert
response.Body.Should().BeSameAs(stream);
}
#endregion
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Microservice.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Microservice SDK tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<!-- Test SDK packages come from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,550 @@
using System.Text;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Common.Tests;
/// <summary>
/// Unit tests for <see cref="FrameConverter"/>.
/// </summary>
public sealed class FrameConverterTests
{
#region ToFrame (RequestFrame) Tests
[Fact]
public void ToFrame_RequestFrame_ReturnsFrameWithRequestType()
{
// Arrange
var request = CreateTestRequestFrame();
// Act
var frame = FrameConverter.ToFrame(request);
// Assert
frame.Type.Should().Be(FrameType.Request);
}
[Fact]
public void ToFrame_RequestFrame_SetsCorrelationIdFromRequest()
{
// Arrange
var request = CreateTestRequestFrame(correlationId: "test-correlation-123");
// Act
var frame = FrameConverter.ToFrame(request);
// Assert
frame.CorrelationId.Should().Be("test-correlation-123");
}
[Fact]
public void ToFrame_RequestFrame_UsesRequestIdWhenCorrelationIdIsNull()
{
// Arrange
var request = new RequestFrame
{
RequestId = "request-id-456",
CorrelationId = null,
Method = "GET",
Path = "/test"
};
// Act
var frame = FrameConverter.ToFrame(request);
// Assert
frame.CorrelationId.Should().Be("request-id-456");
}
[Fact]
public void ToFrame_RequestFrame_SerializesPayload()
{
// Arrange
var request = CreateTestRequestFrame();
// Act
var frame = FrameConverter.ToFrame(request);
// Assert
frame.Payload.Length.Should().BeGreaterThan(0);
}
#endregion
#region ToRequestFrame Tests
[Fact]
public void ToRequestFrame_ValidRequestFrame_ReturnsRequestFrame()
{
// Arrange
var originalRequest = CreateTestRequestFrame();
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result.Should().NotBeNull();
}
[Fact]
public void ToRequestFrame_WrongFrameType_ReturnsNull()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Response,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result.Should().BeNull();
}
[Fact]
public void ToRequestFrame_InvalidJson_ReturnsNull()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Encoding.UTF8.GetBytes("invalid json {{{")
};
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result.Should().BeNull();
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesRequestId()
{
// Arrange
var originalRequest = CreateTestRequestFrame(requestId: "unique-request-id");
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.RequestId.Should().Be("unique-request-id");
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesMethod()
{
// Arrange
var originalRequest = CreateTestRequestFrame(method: "DELETE");
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Method.Should().Be("DELETE");
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesPath()
{
// Arrange
var originalRequest = CreateTestRequestFrame(path: "/api/users/123");
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Path.Should().Be("/api/users/123");
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesHeaders()
{
// Arrange
var headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json",
["X-Custom-Header"] = "custom-value"
};
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "POST",
Path = "/test",
Headers = headers
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Headers.Should().ContainKey("Content-Type");
result.Headers["Content-Type"].Should().Be("application/json");
result.Headers["X-Custom-Header"].Should().Be("custom-value");
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesPayload()
{
// Arrange
var payloadBytes = Encoding.UTF8.GetBytes("{\"key\":\"value\"}");
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "POST",
Path = "/test",
Payload = payloadBytes
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesTimeoutSeconds()
{
// Arrange
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "GET",
Path = "/test",
TimeoutSeconds = 60
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.TimeoutSeconds.Should().Be(60);
}
[Fact]
public void ToRequestFrame_RoundTrip_PreservesSupportsStreaming()
{
// Arrange
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "GET",
Path = "/test",
SupportsStreaming = true
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.SupportsStreaming.Should().BeTrue();
}
#endregion
#region ToFrame (ResponseFrame) Tests
[Fact]
public void ToFrame_ResponseFrame_ReturnsFrameWithResponseType()
{
// Arrange
var response = CreateTestResponseFrame();
// Act
var frame = FrameConverter.ToFrame(response);
// Assert
frame.Type.Should().Be(FrameType.Response);
}
[Fact]
public void ToFrame_ResponseFrame_SetsCorrelationIdToRequestId()
{
// Arrange
var response = CreateTestResponseFrame(requestId: "req-123");
// Act
var frame = FrameConverter.ToFrame(response);
// Assert
frame.CorrelationId.Should().Be("req-123");
}
#endregion
#region ToResponseFrame Tests
[Fact]
public void ToResponseFrame_ValidResponseFrame_ReturnsResponseFrame()
{
// Arrange
var originalResponse = CreateTestResponseFrame();
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result.Should().NotBeNull();
}
[Fact]
public void ToResponseFrame_WrongFrameType_ReturnsNull()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result.Should().BeNull();
}
[Fact]
public void ToResponseFrame_InvalidJson_ReturnsNull()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Response,
CorrelationId = "test",
Payload = Encoding.UTF8.GetBytes("not valid json")
};
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result.Should().BeNull();
}
[Fact]
public void ToResponseFrame_RoundTrip_PreservesRequestId()
{
// Arrange
var originalResponse = CreateTestResponseFrame(requestId: "original-req-id");
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.RequestId.Should().Be("original-req-id");
}
[Fact]
public void ToResponseFrame_RoundTrip_PreservesStatusCode()
{
// Arrange
var originalResponse = CreateTestResponseFrame(statusCode: 404);
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.StatusCode.Should().Be(404);
}
[Fact]
public void ToResponseFrame_RoundTrip_PreservesHeaders()
{
// Arrange
var headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json",
["Cache-Control"] = "no-cache"
};
var originalResponse = new ResponseFrame
{
RequestId = "test-id",
StatusCode = 200,
Headers = headers
};
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.Headers["Content-Type"].Should().Be("application/json");
result.Headers["Cache-Control"].Should().Be("no-cache");
}
[Fact]
public void ToResponseFrame_RoundTrip_PreservesPayload()
{
// Arrange
var payloadBytes = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
var originalResponse = new ResponseFrame
{
RequestId = "test-id",
StatusCode = 200,
Payload = payloadBytes
};
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
}
[Fact]
public void ToResponseFrame_RoundTrip_PreservesHasMoreChunks()
{
// Arrange
var originalResponse = new ResponseFrame
{
RequestId = "test-id",
StatusCode = 200,
HasMoreChunks = true
};
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.HasMoreChunks.Should().BeTrue();
}
#endregion
#region Edge Cases
[Fact]
public void ToRequestFrame_EmptyPayload_ReturnsEmptyPayload()
{
// Arrange
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "GET",
Path = "/test",
Payload = Array.Empty<byte>()
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Payload.IsEmpty.Should().BeTrue();
}
[Fact]
public void ToRequestFrame_NullHeaders_ReturnsEmptyHeaders()
{
// Arrange
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "GET",
Path = "/test"
};
var frame = FrameConverter.ToFrame(originalRequest);
// Act
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result!.Headers.Should().NotBeNull();
result.Headers.Should().BeEmpty();
}
[Fact]
public void ToResponseFrame_EmptyPayload_ReturnsEmptyPayload()
{
// Arrange
var originalResponse = new ResponseFrame
{
RequestId = "test-id",
StatusCode = 204,
Payload = Array.Empty<byte>()
};
var frame = FrameConverter.ToFrame(originalResponse);
// Act
var result = FrameConverter.ToResponseFrame(frame);
// Assert
result!.Payload.IsEmpty.Should().BeTrue();
}
[Fact]
public void ToFrame_LargePayload_Succeeds()
{
// Arrange
var largePayload = new byte[1024 * 1024]; // 1MB
Random.Shared.NextBytes(largePayload);
var originalRequest = new RequestFrame
{
RequestId = "test-id",
Method = "POST",
Path = "/upload",
Payload = largePayload
};
// Act
var frame = FrameConverter.ToFrame(originalRequest);
var result = FrameConverter.ToRequestFrame(frame);
// Assert
result.Should().NotBeNull();
result!.Payload.ToArray().Should().BeEquivalentTo(largePayload);
}
#endregion
#region Helper Methods
private static RequestFrame CreateTestRequestFrame(
string? requestId = null,
string? correlationId = null,
string method = "GET",
string path = "/test")
{
return new RequestFrame
{
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
CorrelationId = correlationId,
Method = method,
Path = path
};
}
private static ResponseFrame CreateTestResponseFrame(
string? requestId = null,
int statusCode = 200)
{
return new ResponseFrame
{
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
StatusCode = statusCode
};
}
#endregion
}

View File

@@ -0,0 +1,463 @@
namespace StellaOps.Router.Common.Tests;
/// <summary>
/// Unit tests for <see cref="PathMatcher"/>.
/// </summary>
public sealed class PathMatcherTests
{
#region Constructor Tests
[Fact]
public void Constructor_SetsTemplate()
{
// Arrange & Act
var matcher = new PathMatcher("/api/users/{id}");
// Assert
matcher.Template.Should().Be("/api/users/{id}");
}
[Fact]
public void Constructor_DefaultsCaseInsensitive()
{
// Arrange & Act
var matcher = new PathMatcher("/api/Users");
// Assert
matcher.IsMatch("/api/users").Should().BeTrue();
}
[Fact]
public void Constructor_CaseSensitive_DoesNotMatchDifferentCase()
{
// Arrange & Act
var matcher = new PathMatcher("/api/Users", caseInsensitive: false);
// Assert
matcher.IsMatch("/api/users").Should().BeFalse();
matcher.IsMatch("/api/Users").Should().BeTrue();
}
#endregion
#region IsMatch Tests - Exact Paths
[Fact]
public void IsMatch_ExactPath_ReturnsTrue()
{
// Arrange
var matcher = new PathMatcher("/api/health");
// Act & Assert
matcher.IsMatch("/api/health").Should().BeTrue();
}
[Fact]
public void IsMatch_ExactPath_TrailingSlash_ReturnsTrue()
{
// Arrange
var matcher = new PathMatcher("/api/health");
// Act & Assert
matcher.IsMatch("/api/health/").Should().BeTrue();
}
[Fact]
public void IsMatch_ExactPath_NoLeadingSlash_ReturnsTrue()
{
// Arrange
var matcher = new PathMatcher("/api/health");
// Act & Assert
matcher.IsMatch("api/health").Should().BeTrue();
}
[Fact]
public void IsMatch_DifferentPath_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/health");
// Act & Assert
matcher.IsMatch("/api/status").Should().BeFalse();
}
[Fact]
public void IsMatch_PartialPath_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/users/list");
// Act & Assert
matcher.IsMatch("/api/users").Should().BeFalse();
}
[Fact]
public void IsMatch_LongerPath_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/users");
// Act & Assert
matcher.IsMatch("/api/users/list").Should().BeFalse();
}
#endregion
#region IsMatch Tests - Case Sensitivity
[Fact]
public void IsMatch_CaseInsensitive_MatchesMixedCase()
{
// Arrange
var matcher = new PathMatcher("/api/users", caseInsensitive: true);
// Act & Assert
matcher.IsMatch("/API/USERS").Should().BeTrue();
matcher.IsMatch("/Api/Users").Should().BeTrue();
matcher.IsMatch("/aPi/uSeRs").Should().BeTrue();
}
[Fact]
public void IsMatch_CaseSensitive_OnlyMatchesExactCase()
{
// Arrange
var matcher = new PathMatcher("/Api/Users", caseInsensitive: false);
// Act & Assert
matcher.IsMatch("/Api/Users").Should().BeTrue();
matcher.IsMatch("/api/users").Should().BeFalse();
matcher.IsMatch("/API/USERS").Should().BeFalse();
}
#endregion
#region TryMatch Tests - Single Parameter
[Fact]
public void TryMatch_SingleParameter_ReturnsTrue()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
var result = matcher.TryMatch("/api/users/123", out var parameters);
// Assert
result.Should().BeTrue();
}
[Fact]
public void TryMatch_SingleParameter_ExtractsParameter()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
matcher.TryMatch("/api/users/123", out var parameters);
// Assert
parameters.Should().ContainKey("id");
parameters["id"].Should().Be("123");
}
[Fact]
public void TryMatch_SingleParameter_ExtractsGuidParameter()
{
// Arrange
var matcher = new PathMatcher("/api/users/{userId}");
var guid = Guid.NewGuid().ToString();
// Act
matcher.TryMatch($"/api/users/{guid}", out var parameters);
// Assert
parameters["userId"].Should().Be(guid);
}
[Fact]
public void TryMatch_SingleParameter_ExtractsStringParameter()
{
// Arrange
var matcher = new PathMatcher("/api/users/{username}");
// Act
matcher.TryMatch("/api/users/john-doe", out var parameters);
// Assert
parameters["username"].Should().Be("john-doe");
}
#endregion
#region TryMatch Tests - Multiple Parameters
[Fact]
public void TryMatch_MultipleParameters_ReturnsTrue()
{
// Arrange
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
// Act
var result = matcher.TryMatch("/api/users/123/posts/456", out _);
// Assert
result.Should().BeTrue();
}
[Fact]
public void TryMatch_MultipleParameters_ExtractsAllParameters()
{
// Arrange
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
// Act
matcher.TryMatch("/api/users/user-1/posts/post-2", out var parameters);
// Assert
parameters.Should().ContainKey("userId");
parameters.Should().ContainKey("postId");
parameters["userId"].Should().Be("user-1");
parameters["postId"].Should().Be("post-2");
}
[Fact]
public void TryMatch_ThreeParameters_ExtractsAllParameters()
{
// Arrange
var matcher = new PathMatcher("/api/org/{orgId}/users/{userId}/roles/{roleId}");
// Act
matcher.TryMatch("/api/org/acme/users/john/roles/admin", out var parameters);
// Assert
parameters.Should().HaveCount(3);
parameters["orgId"].Should().Be("acme");
parameters["userId"].Should().Be("john");
parameters["roleId"].Should().Be("admin");
}
#endregion
#region TryMatch Tests - Non-Matching
[Fact]
public void TryMatch_NonMatchingPath_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
var result = matcher.TryMatch("/api/posts/123", out var parameters);
// Assert
result.Should().BeFalse();
parameters.Should().BeEmpty();
}
[Fact]
public void TryMatch_MissingParameter_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}/posts/{postId}");
// Act
var result = matcher.TryMatch("/api/users/123/posts", out var parameters);
// Assert
result.Should().BeFalse();
}
[Fact]
public void TryMatch_ExtraSegment_ReturnsFalse()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
var result = matcher.TryMatch("/api/users/123/extra", out _);
// Assert
result.Should().BeFalse();
}
#endregion
#region TryMatch Tests - Path Normalization
[Fact]
public void TryMatch_TrailingSlash_Matches()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
var result = matcher.TryMatch("/api/users/123/", out var parameters);
// Assert
result.Should().BeTrue();
parameters["id"].Should().Be("123");
}
[Fact]
public void TryMatch_NoLeadingSlash_Matches()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id}");
// Act
var result = matcher.TryMatch("api/users/123", out var parameters);
// Assert
result.Should().BeTrue();
parameters["id"].Should().Be("123");
}
#endregion
#region TryMatch Tests - Parameter Type Constraints
[Fact]
public void TryMatch_ParameterWithTypeConstraint_ExtractsParameterName()
{
// Arrange
// The PathMatcher ignores type constraints but still extracts the parameter
var matcher = new PathMatcher("/api/users/{id:int}");
// Act
matcher.TryMatch("/api/users/123", out var parameters);
// Assert
parameters.Should().ContainKey("id");
parameters["id"].Should().Be("123");
}
[Fact]
public void TryMatch_ParameterWithGuidConstraint_ExtractsParameterName()
{
// Arrange
var matcher = new PathMatcher("/api/users/{id:guid}");
// Act
matcher.TryMatch("/api/users/abc-123", out var parameters);
// Assert
parameters.Should().ContainKey("id");
parameters["id"].Should().Be("abc-123");
}
#endregion
#region Edge Cases
[Fact]
public void TryMatch_RootPath_Matches()
{
// Arrange
var matcher = new PathMatcher("/");
// Act
var result = matcher.TryMatch("/", out var parameters);
// Assert
result.Should().BeTrue();
parameters.Should().BeEmpty();
}
[Fact]
public void TryMatch_SingleSegmentWithParameter_Matches()
{
// Arrange
var matcher = new PathMatcher("/{id}");
// Act
var result = matcher.TryMatch("/test-value", out var parameters);
// Assert
result.Should().BeTrue();
parameters["id"].Should().Be("test-value");
}
[Fact]
public void IsMatch_EmptyPath_HandlesGracefully()
{
// Arrange
var matcher = new PathMatcher("/");
// Act
var result = matcher.IsMatch("");
// Assert
result.Should().BeTrue();
}
[Fact]
public void TryMatch_ParameterWithHyphen_Extracts()
{
// Arrange
var matcher = new PathMatcher("/api/users/{user-id}");
// Act
matcher.TryMatch("/api/users/123", out var parameters);
// Assert
parameters.Should().ContainKey("user-id");
parameters["user-id"].Should().Be("123");
}
[Fact]
public void TryMatch_ParameterWithUnderscore_Extracts()
{
// Arrange
var matcher = new PathMatcher("/api/users/{user_id}");
// Act
matcher.TryMatch("/api/users/456", out var parameters);
// Assert
parameters.Should().ContainKey("user_id");
}
[Fact]
public void TryMatch_SpecialCharactersInPath_Matches()
{
// Arrange
var matcher = new PathMatcher("/api/search/{query}");
// Act
matcher.TryMatch("/api/search/hello-world_test.123", out var parameters);
// Assert
parameters["query"].Should().Be("hello-world_test.123");
}
[Fact]
public void IsMatch_ComplexRealWorldPath_Matches()
{
// Arrange
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}/vulnerabilities");
// Act
var result = matcher.IsMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001/vulnerabilities");
// Assert
result.Should().BeTrue();
}
[Fact]
public void TryMatch_ComplexRealWorldPath_ExtractsAllParameters()
{
// Arrange
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}");
// Act
matcher.TryMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001", out var parameters);
// Assert
parameters["orgId"].Should().Be("acme-corp");
parameters["projectId"].Should().Be("webapp");
parameters["scanId"].Should().Be("scan-2024-001");
}
#endregion
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Common.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<!-- Test SDK packages come from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="ConfigChangedEventArgs"/>.
/// </summary>
public sealed class ConfigChangedEventArgsTests
{
[Fact]
public void Constructor_SetsPreviousConfig()
{
// Arrange
var previous = new RouterConfig();
var current = new RouterConfig();
// Act
var args = new ConfigChangedEventArgs(previous, current);
// Assert
args.Previous.Should().BeSameAs(previous);
}
[Fact]
public void Constructor_SetsCurrentConfig()
{
// Arrange
var previous = new RouterConfig();
var current = new RouterConfig();
// Act
var args = new ConfigChangedEventArgs(previous, current);
// Assert
args.Current.Should().BeSameAs(current);
}
[Fact]
public void Constructor_SetsChangedAtToCurrentTime()
{
// Arrange
var previous = new RouterConfig();
var current = new RouterConfig();
var beforeCreate = DateTime.UtcNow;
// Act
var args = new ConfigChangedEventArgs(previous, current);
var afterCreate = DateTime.UtcNow;
// Assert
args.ChangedAt.Should().BeOnOrAfter(beforeCreate);
args.ChangedAt.Should().BeOnOrBefore(afterCreate);
}
[Fact]
public void Constructor_DifferentConfigs_BothAccessible()
{
// Arrange
var previous = new RouterConfig
{
Routing = new RoutingOptions { LocalRegion = "us-west-1" }
};
var current = new RouterConfig
{
Routing = new RoutingOptions { LocalRegion = "us-east-1" }
};
// Act
var args = new ConfigChangedEventArgs(previous, current);
// Assert
args.Previous.Routing.LocalRegion.Should().Be("us-west-1");
args.Current.Routing.LocalRegion.Should().Be("us-east-1");
}
[Fact]
public void ConfigChangedEventArgs_InheritsFromEventArgs()
{
// Arrange
var previous = new RouterConfig();
var current = new RouterConfig();
// Act
var args = new ConfigChangedEventArgs(previous, current);
// Assert
args.Should().BeAssignableTo<EventArgs>();
}
}

View File

@@ -0,0 +1,190 @@
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="ConfigValidationResult"/>.
/// </summary>
public sealed class ConfigValidationResultTests
{
#region Default Values Tests
[Fact]
public void Constructor_Errors_DefaultsToEmptyList()
{
// Arrange & Act
var result = new ConfigValidationResult();
// Assert
result.Errors.Should().NotBeNull();
result.Errors.Should().BeEmpty();
}
[Fact]
public void Constructor_Warnings_DefaultsToEmptyList()
{
// Arrange & Act
var result = new ConfigValidationResult();
// Assert
result.Warnings.Should().NotBeNull();
result.Warnings.Should().BeEmpty();
}
#endregion
#region IsValid Tests
[Fact]
public void IsValid_NoErrors_ReturnsTrue()
{
// Arrange
var result = new ConfigValidationResult();
// Act & Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void IsValid_WithErrors_ReturnsFalse()
{
// Arrange
var result = new ConfigValidationResult();
result.Errors.Add("Some error");
// Act & Assert
result.IsValid.Should().BeFalse();
}
[Fact]
public void IsValid_WithOnlyWarnings_ReturnsTrue()
{
// Arrange
var result = new ConfigValidationResult();
result.Warnings.Add("Some warning");
// Act & Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void IsValid_WithErrorsAndWarnings_ReturnsFalse()
{
// Arrange
var result = new ConfigValidationResult();
result.Errors.Add("Some error");
result.Warnings.Add("Some warning");
// Act & Assert
result.IsValid.Should().BeFalse();
}
[Fact]
public void IsValid_MultipleErrors_ReturnsFalse()
{
// Arrange
var result = new ConfigValidationResult();
result.Errors.Add("Error 1");
result.Errors.Add("Error 2");
result.Errors.Add("Error 3");
// Act & Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(3);
}
#endregion
#region Static Success Tests
[Fact]
public void Success_ReturnsValidResult()
{
// Arrange & Act
var result = ConfigValidationResult.Success;
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
result.Warnings.Should().BeEmpty();
}
[Fact]
public void Success_ReturnsNewInstance()
{
// Arrange & Act
var result1 = ConfigValidationResult.Success;
var result2 = ConfigValidationResult.Success;
// Assert - Should be different instances to allow mutation without affecting shared state
result1.Should().NotBeSameAs(result2);
}
#endregion
#region Errors Collection Tests
[Fact]
public void Errors_CanBeModified()
{
// Arrange
var result = new ConfigValidationResult();
// Act
result.Errors.Add("Error 1");
result.Errors.Add("Error 2");
// Assert
result.Errors.Should().HaveCount(2);
result.Errors.Should().Contain("Error 1");
result.Errors.Should().Contain("Error 2");
}
[Fact]
public void Errors_CanBeInitialized()
{
// Arrange & Act
var result = new ConfigValidationResult
{
Errors = ["Error 1", "Error 2"]
};
// Assert
result.Errors.Should().HaveCount(2);
result.IsValid.Should().BeFalse();
}
#endregion
#region Warnings Collection Tests
[Fact]
public void Warnings_CanBeModified()
{
// Arrange
var result = new ConfigValidationResult();
// Act
result.Warnings.Add("Warning 1");
result.Warnings.Add("Warning 2");
// Assert
result.Warnings.Should().HaveCount(2);
result.Warnings.Should().Contain("Warning 1");
result.Warnings.Should().Contain("Warning 2");
}
[Fact]
public void Warnings_CanBeInitialized()
{
// Arrange & Act
var result = new ConfigValidationResult
{
Warnings = ["Warning 1", "Warning 2"]
};
// Assert
result.Warnings.Should().HaveCount(2);
result.IsValid.Should().BeTrue(); // Warnings don't affect validity
}
#endregion
}

View File

@@ -0,0 +1,153 @@
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="RouterConfigOptions"/>.
/// </summary>
public sealed class RouterConfigOptionsTests
{
#region Default Values Tests
[Fact]
public void Constructor_ConfigPath_DefaultsToNull()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.ConfigPath.Should().BeNull();
}
[Fact]
public void Constructor_EnvironmentVariablePrefix_DefaultsToStellaOpsRouter()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.EnvironmentVariablePrefix.Should().Be("STELLAOPS_ROUTER_");
}
[Fact]
public void Constructor_EnableHotReload_DefaultsToTrue()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.EnableHotReload.Should().BeTrue();
}
[Fact]
public void Constructor_DebounceInterval_DefaultsTo500Milliseconds()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.DebounceInterval.Should().Be(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void Constructor_ThrowOnValidationError_DefaultsToFalse()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.ThrowOnValidationError.Should().BeFalse();
}
[Fact]
public void Constructor_ConfigurationSection_DefaultsToRouter()
{
// Arrange & Act
var options = new RouterConfigOptions();
// Assert
options.ConfigurationSection.Should().Be("Router");
}
#endregion
#region Property Assignment Tests
[Fact]
public void ConfigPath_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.ConfigPath = "/etc/stellaops/router.yaml";
// Assert
options.ConfigPath.Should().Be("/etc/stellaops/router.yaml");
}
[Fact]
public void EnvironmentVariablePrefix_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.EnvironmentVariablePrefix = "CUSTOM_PREFIX_";
// Assert
options.EnvironmentVariablePrefix.Should().Be("CUSTOM_PREFIX_");
}
[Fact]
public void EnableHotReload_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.EnableHotReload = false;
// Assert
options.EnableHotReload.Should().BeFalse();
}
[Fact]
public void DebounceInterval_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.DebounceInterval = TimeSpan.FromSeconds(2);
// Assert
options.DebounceInterval.Should().Be(TimeSpan.FromSeconds(2));
}
[Fact]
public void ThrowOnValidationError_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.ThrowOnValidationError = true;
// Assert
options.ThrowOnValidationError.Should().BeTrue();
}
[Fact]
public void ConfigurationSection_CanBeSet()
{
// Arrange
var options = new RouterConfigOptions();
// Act
options.ConfigurationSection = "CustomSection";
// Assert
options.ConfigurationSection.Should().Be("CustomSection");
}
#endregion
}

View File

@@ -0,0 +1,536 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="RouterConfigProvider"/> and configuration validation.
/// </summary>
public sealed class RouterConfigProviderTests : IDisposable
{
private readonly ILogger<RouterConfigProvider> _logger;
private RouterConfigProvider? _provider;
public RouterConfigProviderTests()
{
_logger = NullLogger<RouterConfigProvider>.Instance;
}
public void Dispose()
{
_provider?.Dispose();
}
private RouterConfigProvider CreateProvider(RouterConfigOptions? options = null)
{
var opts = Options.Create(options ?? new RouterConfigOptions { EnableHotReload = false });
_provider = new RouterConfigProvider(opts, _logger);
return _provider;
}
#region Constructor Tests
[Fact]
public void Constructor_InitializesCurrentConfig()
{
// Arrange & Act
var provider = CreateProvider();
// Assert
provider.Current.Should().NotBeNull();
}
[Fact]
public void Constructor_ExposesOptions()
{
// Arrange
var options = new RouterConfigOptions
{
ConfigPath = "/test/path.yaml",
EnableHotReload = false
};
// Act
var provider = CreateProvider(options);
// Assert
provider.Options.Should().NotBeNull();
provider.Options.ConfigPath.Should().Be("/test/path.yaml");
}
[Fact]
public void Constructor_WithHotReloadDisabled_DoesNotThrow()
{
// Arrange
var options = new RouterConfigOptions { EnableHotReload = false };
// Act
var action = () => CreateProvider(options);
// Assert
action.Should().NotThrow();
}
#endregion
#region Validate Tests - PayloadLimits
[Fact]
public void Validate_ValidConfig_ReturnsIsValid()
{
// Arrange
var provider = CreateProvider();
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_ZeroMaxRequestBytesPerCall_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
}
[Fact]
public void Validate_NegativeMaxRequestBytesPerCall_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = -1 };
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
}
[Fact]
public void Validate_ZeroMaxRequestBytesPerConnection_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerConnection = 0 };
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerConnection"));
}
[Fact]
public void Validate_ZeroMaxAggregateInflightBytes_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits { MaxAggregateInflightBytes = 0 };
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("MaxAggregateInflightBytes"));
}
[Fact]
public void Validate_MaxCallBytesLargerThanConnectionBytes_ReturnsWarning()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits
{
MaxRequestBytesPerCall = 100 * 1024 * 1024,
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
MaxAggregateInflightBytes = 1024 * 1024 * 1024
};
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
result.Warnings.Should().Contain(w => w.Contains("MaxRequestBytesPerCall") && w.Contains("MaxRequestBytesPerConnection"));
}
#endregion
#region Validate Tests - RoutingOptions
[Fact]
public void Validate_ZeroDefaultTimeout_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Routing.DefaultTimeout = TimeSpan.Zero;
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
}
[Fact]
public void Validate_NegativeDefaultTimeout_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Routing.DefaultTimeout = TimeSpan.FromSeconds(-1);
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
}
#endregion
#region Validate Tests - Services
[Fact]
public void Validate_EmptyServiceName_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig { ServiceName = "" });
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
}
[Fact]
public void Validate_WhitespaceServiceName_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig { ServiceName = " " });
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
}
[Fact]
public void Validate_DuplicateServiceNames_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
}
[Fact]
public void Validate_DuplicateServiceNamesCaseInsensitive_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig { ServiceName = "MyService" });
provider.Current.Services.Add(new ServiceConfig { ServiceName = "myservice" });
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
}
[Fact]
public void Validate_EndpointEmptyMethod_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig
{
ServiceName = "test",
Endpoints = [new EndpointConfig { Method = "", Path = "/test" }]
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("endpoint method cannot be empty"));
}
[Fact]
public void Validate_EndpointEmptyPath_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig
{
ServiceName = "test",
Endpoints = [new EndpointConfig { Method = "GET", Path = "" }]
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("endpoint path cannot be empty"));
}
[Fact]
public void Validate_EndpointNonPositiveTimeout_ReturnsWarning()
{
// Arrange
var provider = CreateProvider();
provider.Current.Services.Add(new ServiceConfig
{
ServiceName = "test",
Endpoints = [new EndpointConfig { Method = "GET", Path = "/test", DefaultTimeout = TimeSpan.Zero }]
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
result.Warnings.Should().Contain(w => w.Contains("non-positive timeout"));
}
#endregion
#region Validate Tests - StaticInstances
[Fact]
public void Validate_StaticInstanceEmptyServiceName_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "",
Version = "1.0",
Host = "localhost",
Port = 8080
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Static instance service name cannot be empty"));
}
[Fact]
public void Validate_StaticInstanceEmptyHost_ReturnsError()
{
// Arrange
var provider = CreateProvider();
provider.Current.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "",
Port = 8080
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("host cannot be empty"));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
[InlineData(70000)]
public void Validate_StaticInstanceInvalidPort_ReturnsError(int port)
{
// Arrange
var provider = CreateProvider();
provider.Current.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = port
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("port must be between 1 and 65535"));
}
[Theory]
[InlineData(1)]
[InlineData(80)]
[InlineData(443)]
[InlineData(8080)]
[InlineData(65535)]
public void Validate_StaticInstanceValidPort_Succeeds(int port)
{
// Arrange
var provider = CreateProvider();
provider.Current.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = port
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeTrue();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void Validate_StaticInstanceNonPositiveWeight_ReturnsWarning(int weight)
{
// Arrange
var provider = CreateProvider();
provider.Current.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080,
Weight = weight
});
// Act
var result = provider.Validate();
// Assert
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
result.Warnings.Should().Contain(w => w.Contains("weight should be positive"));
}
#endregion
#region ReloadAsync Tests
[Fact]
public async Task ReloadAsync_ValidConfig_UpdatesCurrentConfig()
{
// Arrange
var provider = CreateProvider();
// Act
await provider.ReloadAsync();
// Assert - Config should be reloaded (same content in this case since no file)
provider.Current.Should().NotBeNull();
}
[Fact]
public async Task ReloadAsync_InvalidConfig_ThrowsConfigurationException()
{
// Arrange
var provider = CreateProvider();
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
// Act & Assert
await Assert.ThrowsAsync<ConfigurationException>(() => provider.ReloadAsync());
}
[Fact]
public async Task ReloadAsync_Cancelled_ThrowsOperationCanceledException()
{
// Arrange
var provider = CreateProvider();
var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => provider.ReloadAsync(cts.Token));
}
#endregion
#region ConfigurationChanged Event Tests
[Fact]
public async Task ReloadAsync_RaisesConfigurationChangedEvent()
{
// Arrange
var provider = CreateProvider();
ConfigChangedEventArgs? eventArgs = null;
provider.ConfigurationChanged += (_, args) => eventArgs = args;
// Act
await provider.ReloadAsync();
// Assert
eventArgs.Should().NotBeNull();
eventArgs!.Previous.Should().NotBeNull();
eventArgs.Current.Should().NotBeNull();
eventArgs.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
#endregion
#region Dispose Tests
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var provider = CreateProvider();
// Act
var action = () =>
{
provider.Dispose();
provider.Dispose();
provider.Dispose();
};
// Assert
action.Should().NotThrow();
}
#endregion
}

View File

@@ -0,0 +1,279 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="RouterConfig"/>.
/// </summary>
public sealed class RouterConfigTests
{
#region Default Values Tests
[Fact]
public void Constructor_PayloadLimits_DefaultsToNewInstance()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.PayloadLimits.Should().NotBeNull();
}
[Fact]
public void Constructor_Routing_DefaultsToNewInstance()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.Routing.Should().NotBeNull();
}
[Fact]
public void Constructor_Services_DefaultsToEmptyList()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.Services.Should().NotBeNull();
config.Services.Should().BeEmpty();
}
[Fact]
public void Constructor_StaticInstances_DefaultsToEmptyList()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.StaticInstances.Should().NotBeNull();
config.StaticInstances.Should().BeEmpty();
}
#endregion
#region PayloadLimits Tests
[Fact]
public void PayloadLimits_HasDefaultValues()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(10 * 1024 * 1024); // 10 MB
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(100 * 1024 * 1024); // 100 MB
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(1024 * 1024 * 1024); // 1 GB
}
[Fact]
public void PayloadLimits_CanBeSet()
{
// Arrange
var config = new RouterConfig();
// Act
config.PayloadLimits = new PayloadLimits
{
MaxRequestBytesPerCall = 5 * 1024 * 1024,
MaxRequestBytesPerConnection = 50 * 1024 * 1024,
MaxAggregateInflightBytes = 500 * 1024 * 1024
};
// Assert
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(5 * 1024 * 1024);
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(50 * 1024 * 1024);
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(500 * 1024 * 1024);
}
#endregion
#region Routing Tests
[Fact]
public void Routing_HasDefaultValues()
{
// Arrange & Act
var config = new RouterConfig();
// Assert
config.Routing.LocalRegion.Should().Be("default");
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
config.Routing.PreferLocalRegion.Should().BeTrue();
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void Routing_CanBeSet()
{
// Arrange
var config = new RouterConfig();
// Act
config.Routing = new RoutingOptions
{
LocalRegion = "us-east-1",
TieBreaker = TieBreakerStrategy.LeastLoaded,
PreferLocalRegion = false,
DefaultTimeout = TimeSpan.FromMinutes(2),
NeighborRegions = ["us-west-1", "eu-west-1"]
};
// Assert
config.Routing.LocalRegion.Should().Be("us-east-1");
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.LeastLoaded);
config.Routing.PreferLocalRegion.Should().BeFalse();
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(2));
config.Routing.NeighborRegions.Should().HaveCount(2);
}
#endregion
#region Services Tests
[Fact]
public void Services_CanAddServices()
{
// Arrange
var config = new RouterConfig();
// Act
config.Services.Add(new ServiceConfig { ServiceName = "service-a" });
config.Services.Add(new ServiceConfig { ServiceName = "service-b" });
// Assert
config.Services.Should().HaveCount(2);
config.Services[0].ServiceName.Should().Be("service-a");
config.Services[1].ServiceName.Should().Be("service-b");
}
[Fact]
public void Services_CanBeInitialized()
{
// Arrange & Act
var config = new RouterConfig
{
Services =
[
new ServiceConfig { ServiceName = "auth" },
new ServiceConfig { ServiceName = "users" },
new ServiceConfig { ServiceName = "orders" }
]
};
// Assert
config.Services.Should().HaveCount(3);
}
#endregion
#region StaticInstances Tests
[Fact]
public void StaticInstances_CanAddInstances()
{
// Arrange
var config = new RouterConfig();
// Act
config.StaticInstances.Add(new StaticInstanceConfig
{
ServiceName = "legacy-service",
Version = "1.0",
Host = "legacy.internal",
Port = 9000
});
// Assert
config.StaticInstances.Should().HaveCount(1);
config.StaticInstances[0].ServiceName.Should().Be("legacy-service");
}
[Fact]
public void StaticInstances_CanBeInitialized()
{
// Arrange & Act
var config = new RouterConfig
{
StaticInstances =
[
new StaticInstanceConfig
{
ServiceName = "db-proxy",
Version = "2.0",
Host = "db-proxy-1.internal",
Port = 5432
},
new StaticInstanceConfig
{
ServiceName = "db-proxy",
Version = "2.0",
Host = "db-proxy-2.internal",
Port = 5432
}
]
};
// Assert
config.StaticInstances.Should().HaveCount(2);
}
#endregion
#region Complete Configuration Tests
[Fact]
public void CompleteConfiguration_Works()
{
// Arrange & Act
var config = new RouterConfig
{
PayloadLimits = new PayloadLimits
{
MaxRequestBytesPerCall = 1024 * 1024,
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
MaxAggregateInflightBytes = 100 * 1024 * 1024
},
Routing = new RoutingOptions
{
LocalRegion = "us-east-1",
NeighborRegions = ["us-west-1"],
TieBreaker = TieBreakerStrategy.ConsistentHash,
PreferLocalRegion = true,
DefaultTimeout = TimeSpan.FromSeconds(60)
},
Services =
[
new ServiceConfig
{
ServiceName = "api-gateway",
DefaultVersion = "1.0.0",
Endpoints =
[
new EndpointConfig { Method = "GET", Path = "/health" }
]
}
],
StaticInstances =
[
new StaticInstanceConfig
{
ServiceName = "api-gateway",
Version = "1.0.0",
Host = "api-1.internal",
Port = 8080,
Weight = 100
}
]
};
// Assert
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(1024 * 1024);
config.Routing.LocalRegion.Should().Be("us-east-1");
config.Services.Should().HaveCount(1);
config.StaticInstances.Should().HaveCount(1);
}
#endregion
}

View File

@@ -0,0 +1,179 @@
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="RoutingOptions"/> and <see cref="TieBreakerStrategy"/>.
/// </summary>
public sealed class RoutingOptionsTests
{
#region Default Values Tests
[Fact]
public void Constructor_LocalRegion_DefaultsToDefault()
{
// Arrange & Act
var options = new RoutingOptions();
// Assert
options.LocalRegion.Should().Be("default");
}
[Fact]
public void Constructor_NeighborRegions_DefaultsToEmptyList()
{
// Arrange & Act
var options = new RoutingOptions();
// Assert
options.NeighborRegions.Should().NotBeNull();
options.NeighborRegions.Should().BeEmpty();
}
[Fact]
public void Constructor_TieBreaker_DefaultsToRoundRobin()
{
// Arrange & Act
var options = new RoutingOptions();
// Assert
options.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
}
[Fact]
public void Constructor_PreferLocalRegion_DefaultsToTrue()
{
// Arrange & Act
var options = new RoutingOptions();
// Assert
options.PreferLocalRegion.Should().BeTrue();
}
[Fact]
public void Constructor_DefaultTimeout_DefaultsTo30Seconds()
{
// Arrange & Act
var options = new RoutingOptions();
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
#endregion
#region Property Assignment Tests
[Fact]
public void LocalRegion_CanBeSet()
{
// Arrange
var options = new RoutingOptions();
// Act
options.LocalRegion = "us-east-1";
// Assert
options.LocalRegion.Should().Be("us-east-1");
}
[Fact]
public void NeighborRegions_CanBeSet()
{
// Arrange
var options = new RoutingOptions();
// Act
options.NeighborRegions = ["us-west-1", "eu-west-1"];
// Assert
options.NeighborRegions.Should().HaveCount(2);
options.NeighborRegions.Should().Contain("us-west-1");
options.NeighborRegions.Should().Contain("eu-west-1");
}
[Theory]
[InlineData(TieBreakerStrategy.RoundRobin)]
[InlineData(TieBreakerStrategy.Random)]
[InlineData(TieBreakerStrategy.LeastLoaded)]
[InlineData(TieBreakerStrategy.ConsistentHash)]
public void TieBreaker_CanBeSetToAllStrategies(TieBreakerStrategy strategy)
{
// Arrange
var options = new RoutingOptions();
// Act
options.TieBreaker = strategy;
// Assert
options.TieBreaker.Should().Be(strategy);
}
[Fact]
public void PreferLocalRegion_CanBeSet()
{
// Arrange
var options = new RoutingOptions();
// Act
options.PreferLocalRegion = false;
// Assert
options.PreferLocalRegion.Should().BeFalse();
}
[Fact]
public void DefaultTimeout_CanBeSet()
{
// Arrange
var options = new RoutingOptions();
// Act
options.DefaultTimeout = TimeSpan.FromMinutes(5);
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
}
#endregion
#region TieBreakerStrategy Enum Tests
[Fact]
public void TieBreakerStrategy_HasFourValues()
{
// Arrange & Act
var values = Enum.GetValues<TieBreakerStrategy>();
// Assert
values.Should().HaveCount(4);
}
[Fact]
public void TieBreakerStrategy_RoundRobin_HasValueZero()
{
// Arrange & Act & Assert
((int)TieBreakerStrategy.RoundRobin).Should().Be(0);
}
[Fact]
public void TieBreakerStrategy_Random_HasValueOne()
{
// Arrange & Act & Assert
((int)TieBreakerStrategy.Random).Should().Be(1);
}
[Fact]
public void TieBreakerStrategy_LeastLoaded_HasValueTwo()
{
// Arrange & Act & Assert
((int)TieBreakerStrategy.LeastLoaded).Should().Be(2);
}
[Fact]
public void TieBreakerStrategy_ConsistentHash_HasValueThree()
{
// Arrange & Act & Assert
((int)TieBreakerStrategy.ConsistentHash).Should().Be(3);
}
#endregion
}

View File

@@ -0,0 +1,253 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="ServiceConfig"/> and <see cref="EndpointConfig"/>.
/// </summary>
public sealed class ServiceConfigTests
{
#region ServiceConfig Default Values Tests
[Fact]
public void ServiceConfig_DefaultVersion_DefaultsToNull()
{
// Arrange & Act
var config = new ServiceConfig { ServiceName = "test" };
// Assert
config.DefaultVersion.Should().BeNull();
}
[Fact]
public void ServiceConfig_DefaultTransport_DefaultsToTcp()
{
// Arrange & Act
var config = new ServiceConfig { ServiceName = "test" };
// Assert
config.DefaultTransport.Should().Be(TransportType.Tcp);
}
[Fact]
public void ServiceConfig_Endpoints_DefaultsToEmptyList()
{
// Arrange & Act
var config = new ServiceConfig { ServiceName = "test" };
// Assert
config.Endpoints.Should().NotBeNull();
config.Endpoints.Should().BeEmpty();
}
#endregion
#region ServiceConfig Property Assignment Tests
[Fact]
public void ServiceConfig_ServiceName_CanBeSet()
{
// Arrange & Act
var config = new ServiceConfig { ServiceName = "my-service" };
// Assert
config.ServiceName.Should().Be("my-service");
}
[Fact]
public void ServiceConfig_DefaultVersion_CanBeSet()
{
// Arrange
var config = new ServiceConfig { ServiceName = "test" };
// Act
config.DefaultVersion = "1.0.0";
// Assert
config.DefaultVersion.Should().Be("1.0.0");
}
[Theory]
[InlineData(TransportType.Tcp)]
[InlineData(TransportType.Certificate)]
[InlineData(TransportType.Udp)]
[InlineData(TransportType.InMemory)]
[InlineData(TransportType.RabbitMq)]
public void ServiceConfig_DefaultTransport_CanBeSetToAllTypes(TransportType transport)
{
// Arrange
var config = new ServiceConfig { ServiceName = "test" };
// Act
config.DefaultTransport = transport;
// Assert
config.DefaultTransport.Should().Be(transport);
}
[Fact]
public void ServiceConfig_Endpoints_CanAddEndpoints()
{
// Arrange
var config = new ServiceConfig { ServiceName = "test" };
// Act
config.Endpoints.Add(new EndpointConfig { Method = "GET", Path = "/api/health" });
config.Endpoints.Add(new EndpointConfig { Method = "POST", Path = "/api/data" });
// Assert
config.Endpoints.Should().HaveCount(2);
}
#endregion
#region EndpointConfig Default Values Tests
[Fact]
public void EndpointConfig_DefaultTimeout_DefaultsToNull()
{
// Arrange & Act
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Assert
endpoint.DefaultTimeout.Should().BeNull();
}
[Fact]
public void EndpointConfig_SupportsStreaming_DefaultsToFalse()
{
// Arrange & Act
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Assert
endpoint.SupportsStreaming.Should().BeFalse();
}
[Fact]
public void EndpointConfig_RequiringClaims_DefaultsToEmptyList()
{
// Arrange & Act
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Assert
endpoint.RequiringClaims.Should().NotBeNull();
endpoint.RequiringClaims.Should().BeEmpty();
}
#endregion
#region EndpointConfig Property Assignment Tests
[Fact]
public void EndpointConfig_Method_CanBeSet()
{
// Arrange & Act
var endpoint = new EndpointConfig { Method = "DELETE", Path = "/" };
// Assert
endpoint.Method.Should().Be("DELETE");
}
[Fact]
public void EndpointConfig_Path_CanBeSet()
{
// Arrange & Act
var endpoint = new EndpointConfig { Method = "GET", Path = "/api/users/{id}" };
// Assert
endpoint.Path.Should().Be("/api/users/{id}");
}
[Fact]
public void EndpointConfig_DefaultTimeout_CanBeSet()
{
// Arrange
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Act
endpoint.DefaultTimeout = TimeSpan.FromSeconds(60);
// Assert
endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(60));
}
[Fact]
public void EndpointConfig_SupportsStreaming_CanBeSet()
{
// Arrange
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Act
endpoint.SupportsStreaming = true;
// Assert
endpoint.SupportsStreaming.Should().BeTrue();
}
[Fact]
public void EndpointConfig_RequiringClaims_CanAddClaims()
{
// Arrange
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
// Act
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "role", Value = "admin" });
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "permission", Value = "read" });
// Assert
endpoint.RequiringClaims.Should().HaveCount(2);
}
#endregion
#region Complex Configuration Tests
[Fact]
public void ServiceConfig_CompleteConfiguration_Works()
{
// Arrange & Act
var config = new ServiceConfig
{
ServiceName = "user-service",
DefaultVersion = "2.0.0",
DefaultTransport = TransportType.Certificate,
Endpoints =
[
new EndpointConfig
{
Method = "GET",
Path = "/api/users/{id}",
DefaultTimeout = TimeSpan.FromSeconds(10),
SupportsStreaming = false,
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
},
new EndpointConfig
{
Method = "POST",
Path = "/api/users",
DefaultTimeout = TimeSpan.FromSeconds(30),
SupportsStreaming = false,
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
},
new EndpointConfig
{
Method = "GET",
Path = "/api/users/stream",
DefaultTimeout = TimeSpan.FromMinutes(5),
SupportsStreaming = true
}
]
};
// Assert
config.ServiceName.Should().Be("user-service");
config.DefaultVersion.Should().Be("2.0.0");
config.DefaultTransport.Should().Be(TransportType.Certificate);
config.Endpoints.Should().HaveCount(3);
config.Endpoints[0].RequiringClaims.Should().HaveCount(1);
config.Endpoints[2].SupportsStreaming.Should().BeTrue();
}
#endregion
}

View File

@@ -0,0 +1,311 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.Config.Tests;
/// <summary>
/// Unit tests for <see cref="StaticInstanceConfig"/>.
/// </summary>
public sealed class StaticInstanceConfigTests
{
#region Default Values Tests
[Fact]
public void Constructor_Region_DefaultsToDefault()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Assert
config.Region.Should().Be("default");
}
[Fact]
public void Constructor_Transport_DefaultsToTcp()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Assert
config.Transport.Should().Be(TransportType.Tcp);
}
[Fact]
public void Constructor_Weight_DefaultsTo100()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Assert
config.Weight.Should().Be(100);
}
[Fact]
public void Constructor_Metadata_DefaultsToEmptyDictionary()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Assert
config.Metadata.Should().NotBeNull();
config.Metadata.Should().BeEmpty();
}
#endregion
#region Required Properties Tests
[Fact]
public void ServiceName_IsRequired()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "required-service",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Assert
config.ServiceName.Should().Be("required-service");
}
[Fact]
public void Version_IsRequired()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "2.3.4",
Host = "localhost",
Port = 8080
};
// Assert
config.Version.Should().Be("2.3.4");
}
[Fact]
public void Host_IsRequired()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "192.168.1.100",
Port = 8080
};
// Assert
config.Host.Should().Be("192.168.1.100");
}
[Fact]
public void Port_IsRequired()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 443
};
// Assert
config.Port.Should().Be(443);
}
#endregion
#region Property Assignment Tests
[Fact]
public void Region_CanBeSet()
{
// Arrange
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Act
config.Region = "us-west-2";
// Assert
config.Region.Should().Be("us-west-2");
}
[Theory]
[InlineData(TransportType.Tcp)]
[InlineData(TransportType.Certificate)]
[InlineData(TransportType.Udp)]
[InlineData(TransportType.InMemory)]
[InlineData(TransportType.RabbitMq)]
public void Transport_CanBeSetToAllTypes(TransportType transport)
{
// Arrange
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Act
config.Transport = transport;
// Assert
config.Transport.Should().Be(transport);
}
[Theory]
[InlineData(1)]
[InlineData(50)]
[InlineData(100)]
[InlineData(200)]
[InlineData(1000)]
public void Weight_CanBeSet(int weight)
{
// Arrange
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Act
config.Weight = weight;
// Assert
config.Weight.Should().Be(weight);
}
[Fact]
public void Metadata_CanAddEntries()
{
// Arrange
var config = new StaticInstanceConfig
{
ServiceName = "test",
Version = "1.0",
Host = "localhost",
Port = 8080
};
// Act
config.Metadata["environment"] = "production";
config.Metadata["cluster"] = "primary";
// Assert
config.Metadata.Should().HaveCount(2);
config.Metadata["environment"].Should().Be("production");
config.Metadata["cluster"].Should().Be("primary");
}
#endregion
#region Complex Configuration Tests
[Fact]
public void CompleteConfiguration_Works()
{
// Arrange & Act
var config = new StaticInstanceConfig
{
ServiceName = "user-service",
Version = "3.2.1",
Region = "eu-central-1",
Host = "user-svc.internal.example.com",
Port = 8443,
Transport = TransportType.Certificate,
Weight = 150,
Metadata = new Dictionary<string, string>
{
["datacenter"] = "dc1",
["rack"] = "rack-42",
["shard"] = "primary"
}
};
// Assert
config.ServiceName.Should().Be("user-service");
config.Version.Should().Be("3.2.1");
config.Region.Should().Be("eu-central-1");
config.Host.Should().Be("user-svc.internal.example.com");
config.Port.Should().Be(8443);
config.Transport.Should().Be(TransportType.Certificate);
config.Weight.Should().Be(150);
config.Metadata.Should().HaveCount(3);
}
[Fact]
public void MultipleInstances_CanHaveDifferentWeights()
{
// Arrange & Act
var primary = new StaticInstanceConfig
{
ServiceName = "api",
Version = "1.0",
Host = "primary.example.com",
Port = 8080,
Weight = 200
};
var secondary = new StaticInstanceConfig
{
ServiceName = "api",
Version = "1.0",
Host = "secondary.example.com",
Port = 8080,
Weight = 100
};
var tertiary = new StaticInstanceConfig
{
ServiceName = "api",
Version = "1.0",
Host = "tertiary.example.com",
Port = 8080,
Weight = 50
};
// Assert
primary.Weight.Should().BeGreaterThan(secondary.Weight);
secondary.Weight.Should().BeGreaterThan(tertiary.Weight);
}
#endregion
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Config.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<!-- Test SDK packages come from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,210 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Testing.Factories;
/// <summary>
/// Factory for creating test frames with sensible defaults.
/// </summary>
public static class TestFrameFactory
{
/// <summary>
/// Creates a request frame with the specified payload.
/// </summary>
public static Frame CreateRequestFrame(
byte[]? payload = null,
string? correlationId = null,
FrameType frameType = FrameType.Request)
{
return new Frame
{
Type = frameType,
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
Payload = payload ?? Array.Empty<byte>()
};
}
/// <summary>
/// Creates a response frame for the given correlation ID.
/// </summary>
public static Frame CreateResponseFrame(
string correlationId,
byte[]? payload = null)
{
return new Frame
{
Type = FrameType.Response,
CorrelationId = correlationId,
Payload = payload ?? Array.Empty<byte>()
};
}
/// <summary>
/// Creates a hello frame for service registration.
/// </summary>
public static Frame CreateHelloFrame(
string serviceName = "test-service",
string version = "1.0.0",
string region = "test",
string instanceId = "test-instance",
IReadOnlyList<EndpointDescriptor>? endpoints = null)
{
var helloPayload = new HelloPayload
{
Instance = new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = region
},
Endpoints = endpoints ?? []
};
return new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(helloPayload)
};
}
/// <summary>
/// Creates a heartbeat frame.
/// </summary>
public static Frame CreateHeartbeatFrame(
string instanceId = "test-instance",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
int inFlightRequestCount = 0,
double errorRate = 0.0)
{
var heartbeatPayload = new HeartbeatPayload
{
InstanceId = instanceId,
Status = status,
InFlightRequestCount = inFlightRequestCount,
ErrorRate = errorRate,
TimestampUtc = DateTime.UtcNow
};
return new Frame
{
Type = FrameType.Heartbeat,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(heartbeatPayload)
};
}
/// <summary>
/// Creates a cancel frame for the given correlation ID.
/// </summary>
public static Frame CreateCancelFrame(
string correlationId,
string? reason = null)
{
var cancelPayload = new CancelPayload
{
Reason = reason ?? CancelReasons.Timeout
};
return new Frame
{
Type = FrameType.Cancel,
CorrelationId = correlationId,
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cancelPayload)
};
}
/// <summary>
/// Creates a frame with a specific payload size for testing limits.
/// </summary>
public static Frame CreateFrameWithPayloadSize(int payloadSize)
{
var payload = new byte[payloadSize];
Random.Shared.NextBytes(payload);
return new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = payload
};
}
/// <summary>
/// Creates a request frame from JSON content.
/// </summary>
public static RequestFrame CreateTypedRequestFrame<T>(
T request,
string method = "POST",
string path = "/test",
Dictionary<string, string>? headers = null)
{
return new RequestFrame
{
RequestId = Guid.NewGuid().ToString("N"),
CorrelationId = Guid.NewGuid().ToString("N"),
Method = method,
Path = path,
Headers = headers ?? new Dictionary<string, string>(),
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(request)
};
}
/// <summary>
/// Creates an endpoint descriptor for testing.
/// </summary>
public static EndpointDescriptor CreateEndpointDescriptor(
string method = "GET",
string path = "/test",
string serviceName = "test-service",
string version = "1.0.0",
int timeoutSeconds = 30,
bool supportsStreaming = false,
IReadOnlyList<ClaimRequirement>? requiringClaims = null)
{
return new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version,
DefaultTimeout = TimeSpan.FromSeconds(timeoutSeconds),
SupportsStreaming = supportsStreaming,
RequiringClaims = requiringClaims ?? []
};
}
/// <summary>
/// Creates an instance descriptor for testing.
/// </summary>
public static InstanceDescriptor CreateInstanceDescriptor(
string instanceId = "test-instance",
string serviceName = "test-service",
string version = "1.0.0",
string region = "test")
{
return new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = region
};
}
/// <summary>
/// Creates a claim requirement for testing.
/// </summary>
public static ClaimRequirement CreateClaimRequirement(
string type,
string? value = null)
{
return new ClaimRequirement
{
Type = type,
Value = value
};
}
}

View File

@@ -0,0 +1,102 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Router.Testing.Fixtures;
/// <summary>
/// Base test fixture for Router tests providing common utilities.
/// Implements IAsyncLifetime for async setup/teardown.
/// </summary>
public abstract class RouterTestFixture : IAsyncLifetime
{
/// <summary>
/// Gets a null logger factory for tests that don't need logging.
/// </summary>
protected ILoggerFactory LoggerFactory { get; } = NullLoggerFactory.Instance;
/// <summary>
/// Gets a null logger for tests that don't need logging.
/// </summary>
protected ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
/// <summary>
/// Creates a cancellation token that times out after the specified duration.
/// </summary>
protected static CancellationToken CreateTimeoutToken(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
return cts.Token;
}
/// <summary>
/// Creates a cancellation token that times out after 5 seconds (default for tests).
/// </summary>
protected static CancellationToken CreateTestTimeoutToken()
{
return CreateTimeoutToken(TimeSpan.FromSeconds(5));
}
/// <summary>
/// Waits for a condition to be true with timeout.
/// </summary>
protected static async Task WaitForConditionAsync(
Func<bool> condition,
TimeSpan timeout,
TimeSpan? pollInterval = null)
{
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(interval);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
/// <summary>
/// Waits for an async condition to be true with timeout.
/// </summary>
protected static async Task WaitForConditionAsync(
Func<Task<bool>> condition,
TimeSpan timeout,
TimeSpan? pollInterval = null)
{
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (await condition())
return;
await Task.Delay(interval);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
/// <summary>
/// Override for async initialization.
/// </summary>
public virtual Task InitializeAsync() => Task.CompletedTask;
/// <summary>
/// Override for async cleanup.
/// </summary>
public virtual Task DisposeAsync() => Task.CompletedTask;
}
/// <summary>
/// Collection fixture for sharing state across tests in the same collection.
/// </summary>
public abstract class RouterCollectionFixture : IAsyncLifetime
{
public virtual Task InitializeAsync() => Task.CompletedTask;
public virtual Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -0,0 +1,56 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Testing.Mocks;
/// <summary>
/// A mock connection state for testing routing and connection management.
/// </summary>
public sealed class MockConnectionState
{
public string ConnectionId { get; init; } = Guid.NewGuid().ToString("N");
public string ServiceName { get; init; } = "test-service";
public string Version { get; init; } = "1.0.0";
public string Region { get; init; } = "test";
public string InstanceId { get; init; } = "test-instance";
public InstanceHealthStatus HealthStatus { get; set; } = InstanceHealthStatus.Healthy;
public DateTimeOffset ConnectedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastHeartbeatUtc { get; set; } = DateTimeOffset.UtcNow;
public int InflightRequests { get; set; }
public int Weight { get; set; } = 100;
public List<EndpointDescriptor> Endpoints { get; init; } = new();
/// <summary>
/// Creates a connection state for testing.
/// </summary>
public static MockConnectionState Create(
string? serviceName = null,
string? instanceId = null,
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return new MockConnectionState
{
ServiceName = serviceName ?? "test-service",
InstanceId = instanceId ?? $"instance-{Guid.NewGuid():N}",
HealthStatus = status
};
}
/// <summary>
/// Creates multiple connection states simulating a service cluster.
/// </summary>
public static List<MockConnectionState> CreateCluster(
string serviceName,
int instanceCount,
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return Enumerable.Range(0, instanceCount)
.Select(i => new MockConnectionState
{
ServiceName = serviceName,
InstanceId = $"{serviceName}-{i}",
HealthStatus = status
})
.ToList();
}
}

View File

@@ -0,0 +1,104 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace StellaOps.Router.Testing.Mocks;
/// <summary>
/// A logger that records all log entries for assertions.
/// </summary>
public sealed class RecordingLogger<T> : ILogger<T>
{
private readonly ConcurrentQueue<LogEntry> _entries = new();
/// <summary>
/// Gets all recorded log entries.
/// </summary>
public IReadOnlyList<LogEntry> Entries => _entries.ToList();
/// <summary>
/// Gets entries filtered by log level.
/// </summary>
public IEnumerable<LogEntry> GetEntries(LogLevel level) =>
_entries.Where(e => e.Level == level);
/// <summary>
/// Gets all error entries.
/// </summary>
public IEnumerable<LogEntry> Errors => GetEntries(LogLevel.Error);
/// <summary>
/// Gets all warning entries.
/// </summary>
public IEnumerable<LogEntry> Warnings => GetEntries(LogLevel.Warning);
/// <summary>
/// Clears all recorded entries.
/// </summary>
public void Clear()
{
while (_entries.TryDequeue(out _)) { }
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
_entries.Enqueue(new LogEntry
{
Level = logLevel,
EventId = eventId,
Message = formatter(state, exception),
Exception = exception,
Timestamp = DateTimeOffset.UtcNow
});
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
/// <summary>
/// Represents a recorded log entry.
/// </summary>
public sealed record LogEntry
{
public required LogLevel Level { get; init; }
public required EventId EventId { get; init; }
public required string Message { get; init; }
public Exception? Exception { get; init; }
public DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// A logger factory that creates recording loggers.
/// </summary>
public sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly ConcurrentDictionary<string, object> _loggers = new();
public ILogger CreateLogger(string categoryName) =>
(ILogger)_loggers.GetOrAdd(categoryName, _ => new RecordingLogger<object>());
public ILogger<T> CreateLogger<T>() =>
(ILogger<T>)_loggers.GetOrAdd(typeof(T).FullName!, _ => new RecordingLogger<T>());
public RecordingLogger<T>? GetLogger<T>() =>
_loggers.TryGetValue(typeof(T).FullName!, out var logger)
? logger as RecordingLogger<T>
: null;
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Testing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,300 @@
using System.Threading.Channels;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Transport.InMemory.Tests;
/// <summary>
/// Unit tests for <see cref="InMemoryChannel"/>.
/// </summary>
public sealed class InMemoryChannelTests
{
private static InstanceDescriptor CreateTestInstance()
{
return new InstanceDescriptor
{
InstanceId = "inst-456",
ServiceName = "test-service",
Version = "1.0.0",
Region = "default"
};
}
private static ConnectionState CreateTestConnectionState(string connectionId)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = CreateTestInstance(),
TransportType = TransportType.InMemory
};
}
#region Constructor Tests
[Fact]
public void Constructor_SetsConnectionId()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123");
// Assert
channel.ConnectionId.Should().Be("conn-123");
}
[Fact]
public void Constructor_CreatesUnboundedChannels_ByDefault()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123");
// Assert - channels should be able to accept multiple items without blocking
channel.ToMicroservice.Should().NotBeNull();
channel.ToGateway.Should().NotBeNull();
}
[Fact]
public void Constructor_CreatesBoundedChannels_WhenBufferSizeSpecified()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123", bufferSize: 10);
// Assert
channel.ToMicroservice.Should().NotBeNull();
channel.ToGateway.Should().NotBeNull();
}
[Fact]
public void Constructor_CreatesLifetimeToken()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123");
// Assert
channel.LifetimeToken.Should().NotBeNull();
channel.LifetimeToken.IsCancellationRequested.Should().BeFalse();
}
[Fact]
public void Constructor_Instance_IsInitiallyNull()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123");
// Assert
channel.Instance.Should().BeNull();
}
[Fact]
public void Constructor_State_IsInitiallyNull()
{
// Arrange & Act
using var channel = new InMemoryChannel("conn-123");
// Assert
channel.State.Should().BeNull();
}
#endregion
#region Property Assignment Tests
[Fact]
public void Instance_CanBeSet()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
var instance = CreateTestInstance();
// Act
channel.Instance = instance;
// Assert
channel.Instance.Should().BeSameAs(instance);
channel.Instance.ServiceName.Should().Be("test-service");
}
[Fact]
public void State_CanBeSet()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
var state = CreateTestConnectionState("conn-123");
// Act
channel.State = state;
// Assert
channel.State.Should().BeSameAs(state);
}
#endregion
#region Channel Communication Tests
[Fact]
public async Task ToMicroservice_CanWriteAndRead()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "corr-123",
Payload = new byte[] { 1, 2, 3 }
};
// Act
await channel.ToMicroservice.Writer.WriteAsync(frame);
var received = await channel.ToMicroservice.Reader.ReadAsync();
// Assert
received.Should().BeSameAs(frame);
}
[Fact]
public async Task ToGateway_CanWriteAndRead()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
var frame = new Frame
{
Type = FrameType.Response,
CorrelationId = "corr-123",
Payload = new byte[] { 4, 5, 6 }
};
// Act
await channel.ToGateway.Writer.WriteAsync(frame);
var received = await channel.ToGateway.Reader.ReadAsync();
// Assert
received.Should().BeSameAs(frame);
}
[Fact]
public async Task Channel_MultipleFrames_DeliveredInOrder()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
var frame1 = new Frame { Type = FrameType.Request, CorrelationId = "1", Payload = Array.Empty<byte>() };
var frame2 = new Frame { Type = FrameType.Request, CorrelationId = "2", Payload = Array.Empty<byte>() };
var frame3 = new Frame { Type = FrameType.Request, CorrelationId = "3", Payload = Array.Empty<byte>() };
// Act
await channel.ToMicroservice.Writer.WriteAsync(frame1);
await channel.ToMicroservice.Writer.WriteAsync(frame2);
await channel.ToMicroservice.Writer.WriteAsync(frame3);
var received1 = await channel.ToMicroservice.Reader.ReadAsync();
var received2 = await channel.ToMicroservice.Reader.ReadAsync();
var received3 = await channel.ToMicroservice.Reader.ReadAsync();
// Assert - FIFO ordering
received1.CorrelationId.Should().Be("1");
received2.CorrelationId.Should().Be("2");
received3.CorrelationId.Should().Be("3");
}
#endregion
#region Bounded Channel Tests
[Fact]
public async Task BoundedChannel_AcceptsUpToBufferSize()
{
// Arrange
using var channel = new InMemoryChannel("conn-123", bufferSize: 3);
var frame = new Frame { Type = FrameType.Request, CorrelationId = "test", Payload = Array.Empty<byte>() };
// Act & Assert - should accept 3 without blocking
await channel.ToMicroservice.Writer.WriteAsync(frame);
await channel.ToMicroservice.Writer.WriteAsync(frame);
await channel.ToMicroservice.Writer.WriteAsync(frame);
// Channel now at capacity
var tryWrite = channel.ToMicroservice.Writer.TryWrite(frame);
tryWrite.Should().BeFalse(); // Full, can't write synchronously
}
#endregion
#region Dispose Tests
[Fact]
public void Dispose_CancelsLifetimeToken()
{
// Arrange
var channel = new InMemoryChannel("conn-123");
// Act
channel.Dispose();
// Assert
channel.LifetimeToken.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void Dispose_CompletesChannels()
{
// Arrange
var channel = new InMemoryChannel("conn-123");
// Act
channel.Dispose();
// Assert
channel.ToMicroservice.Reader.Completion.IsCompleted.Should().BeTrue();
channel.ToGateway.Reader.Completion.IsCompleted.Should().BeTrue();
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var channel = new InMemoryChannel("conn-123");
// Act
var action = () =>
{
channel.Dispose();
channel.Dispose();
channel.Dispose();
};
// Assert
action.Should().NotThrow();
}
[Fact]
public async Task Dispose_ReaderDetectsCompletion()
{
// Arrange
using var channel = new InMemoryChannel("conn-123");
// Start reader task
var readerTask = Task.Run(async () =>
{
var completed = false;
try
{
await channel.ToMicroservice.Reader.ReadAsync();
}
catch (ChannelClosedException)
{
completed = true;
}
return completed;
});
// Act
await Task.Delay(50); // Give reader time to start waiting
channel.Dispose();
// Assert
var result = await readerTask;
result.Should().BeTrue();
}
#endregion
}

View File

@@ -0,0 +1,458 @@
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Transport.InMemory.Tests;
/// <summary>
/// Unit tests for <see cref="InMemoryConnectionRegistry"/>.
/// </summary>
public sealed class InMemoryConnectionRegistryTests : IDisposable
{
private readonly InMemoryConnectionRegistry _registry;
public InMemoryConnectionRegistryTests()
{
_registry = new InMemoryConnectionRegistry();
}
public void Dispose()
{
_registry.Dispose();
}
private static InstanceDescriptor CreateTestInstance(string instanceId = "inst-1", string serviceName = "test-service", string version = "1.0")
{
return new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = serviceName,
Version = version,
Region = "default"
};
}
private static ConnectionState CreateTestConnectionState(string connectionId)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = CreateTestInstance(),
TransportType = TransportType.InMemory
};
}
#region CreateChannel Tests
[Fact]
public void CreateChannel_ReturnsNewChannel()
{
// Arrange & Act
var channel = _registry.CreateChannel("conn-123");
// Assert
channel.Should().NotBeNull();
channel.ConnectionId.Should().Be("conn-123");
}
[Fact]
public void CreateChannel_IncreasesCount()
{
// Arrange
_registry.Count.Should().Be(0);
// Act
_registry.CreateChannel("conn-123");
// Assert
_registry.Count.Should().Be(1);
}
[Fact]
public void CreateChannel_WithBufferSize_CreatesCorrectChannel()
{
// Arrange & Act
var channel = _registry.CreateChannel("conn-123", bufferSize: 100);
// Assert
channel.Should().NotBeNull();
}
[Fact]
public void CreateChannel_DuplicateId_ThrowsInvalidOperationException()
{
// Arrange
_registry.CreateChannel("conn-123");
// Act
var action = () => _registry.CreateChannel("conn-123");
// Assert
action.Should().Throw<InvalidOperationException>()
.WithMessage("*conn-123*already exists*");
}
[Fact]
public void CreateChannel_AfterDispose_ThrowsObjectDisposedException()
{
// Arrange
_registry.Dispose();
// Act
var action = () => _registry.CreateChannel("conn-123");
// Assert
action.Should().Throw<ObjectDisposedException>();
}
#endregion
#region GetChannel Tests
[Fact]
public void GetChannel_ExistingConnection_ReturnsChannel()
{
// Arrange
var created = _registry.CreateChannel("conn-123");
// Act
var retrieved = _registry.GetChannel("conn-123");
// Assert
retrieved.Should().BeSameAs(created);
}
[Fact]
public void GetChannel_NonexistentConnection_ReturnsNull()
{
// Arrange & Act
var retrieved = _registry.GetChannel("nonexistent");
// Assert
retrieved.Should().BeNull();
}
#endregion
#region GetRequiredChannel Tests
[Fact]
public void GetRequiredChannel_ExistingConnection_ReturnsChannel()
{
// Arrange
var created = _registry.CreateChannel("conn-123");
// Act
var retrieved = _registry.GetRequiredChannel("conn-123");
// Assert
retrieved.Should().BeSameAs(created);
}
[Fact]
public void GetRequiredChannel_NonexistentConnection_ThrowsInvalidOperationException()
{
// Arrange & Act
var action = () => _registry.GetRequiredChannel("nonexistent");
// Assert
action.Should().Throw<InvalidOperationException>()
.WithMessage("*nonexistent*not found*");
}
#endregion
#region RemoveChannel Tests
[Fact]
public void RemoveChannel_ExistingConnection_ReturnsTrue()
{
// Arrange
_registry.CreateChannel("conn-123");
// Act
var result = _registry.RemoveChannel("conn-123");
// Assert
result.Should().BeTrue();
_registry.Count.Should().Be(0);
}
[Fact]
public void RemoveChannel_NonexistentConnection_ReturnsFalse()
{
// Arrange & Act
var result = _registry.RemoveChannel("nonexistent");
// Assert
result.Should().BeFalse();
}
[Fact]
public void RemoveChannel_DisposesChannel()
{
// Arrange
var channel = _registry.CreateChannel("conn-123");
var token = channel.LifetimeToken;
// Act
_registry.RemoveChannel("conn-123");
// Assert
token.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void RemoveChannel_CannotGetAfterRemove()
{
// Arrange
_registry.CreateChannel("conn-123");
// Act
_registry.RemoveChannel("conn-123");
var retrieved = _registry.GetChannel("conn-123");
// Assert
retrieved.Should().BeNull();
}
#endregion
#region ConnectionIds Tests
[Fact]
public void ConnectionIds_EmptyRegistry_ReturnsEmpty()
{
// Arrange & Act
var ids = _registry.ConnectionIds;
// Assert
ids.Should().BeEmpty();
}
[Fact]
public void ConnectionIds_WithConnections_ReturnsAllIds()
{
// Arrange
_registry.CreateChannel("conn-1");
_registry.CreateChannel("conn-2");
_registry.CreateChannel("conn-3");
// Act
var ids = _registry.ConnectionIds.ToList();
// Assert
ids.Should().HaveCount(3);
ids.Should().Contain("conn-1");
ids.Should().Contain("conn-2");
ids.Should().Contain("conn-3");
}
#endregion
#region Count Tests
[Fact]
public void Count_EmptyRegistry_IsZero()
{
// Arrange & Act & Assert
_registry.Count.Should().Be(0);
}
[Fact]
public void Count_ReflectsActiveConnections()
{
// Arrange & Act
_registry.CreateChannel("conn-1");
_registry.CreateChannel("conn-2");
_registry.Count.Should().Be(2);
_registry.RemoveChannel("conn-1");
_registry.Count.Should().Be(1);
}
#endregion
#region GetAllConnections Tests
[Fact]
public void GetAllConnections_EmptyRegistry_ReturnsEmpty()
{
// Arrange & Act
var connections = _registry.GetAllConnections();
// Assert
connections.Should().BeEmpty();
}
[Fact]
public void GetAllConnections_ChannelsWithoutState_ReturnsEmpty()
{
// Arrange
_registry.CreateChannel("conn-1");
_registry.CreateChannel("conn-2");
// Act
var connections = _registry.GetAllConnections();
// Assert
connections.Should().BeEmpty(); // No State set on channels
}
[Fact]
public void GetAllConnections_ChannelsWithState_ReturnsStates()
{
// Arrange
var channel1 = _registry.CreateChannel("conn-1");
channel1.State = CreateTestConnectionState("conn-1");
var channel2 = _registry.CreateChannel("conn-2");
channel2.State = CreateTestConnectionState("conn-2");
// Act
var connections = _registry.GetAllConnections();
// Assert
connections.Should().HaveCount(2);
}
#endregion
#region GetConnectionsFor Tests
[Fact]
public void GetConnectionsFor_NoMatchingConnections_ReturnsEmpty()
{
// Arrange
_registry.CreateChannel("conn-1");
// Act
var connections = _registry.GetConnectionsFor("test-service", "1.0", "GET", "/api/users");
// Assert
connections.Should().BeEmpty();
}
[Fact]
public void GetConnectionsFor_MatchingServiceAndEndpoint_ReturnsConnections()
{
// Arrange
var channel = _registry.CreateChannel("conn-1");
channel.Instance = CreateTestInstance("inst-1", "test-service", "1.0");
channel.State = CreateTestConnectionState("conn-1");
channel.State.Endpoints[("GET", "/api/users")] = new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0",
Method = "GET",
Path = "/api/users"
};
// Act
var connections = _registry.GetConnectionsFor("test-service", "1.0", "GET", "/api/users");
// Assert
connections.Should().HaveCount(1);
}
[Fact]
public void GetConnectionsFor_MismatchedVersion_ReturnsEmpty()
{
// Arrange
var channel = _registry.CreateChannel("conn-1");
channel.Instance = CreateTestInstance("inst-1", "test-service", "1.0");
channel.State = CreateTestConnectionState("conn-1");
channel.State.Endpoints[("GET", "/api/users")] = new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0",
Method = "GET",
Path = "/api/users"
};
// Act
var connections = _registry.GetConnectionsFor("test-service", "2.0", "GET", "/api/users");
// Assert
connections.Should().BeEmpty();
}
#endregion
#region Dispose Tests
[Fact]
public void Dispose_DisposesAllChannels()
{
// Arrange
var channel1 = _registry.CreateChannel("conn-1");
var channel2 = _registry.CreateChannel("conn-2");
var token1 = channel1.LifetimeToken;
var token2 = channel2.LifetimeToken;
// Act
_registry.Dispose();
// Assert
token1.IsCancellationRequested.Should().BeTrue();
token2.IsCancellationRequested.Should().BeTrue();
}
[Fact]
public void Dispose_ClearsRegistry()
{
// Arrange
_registry.CreateChannel("conn-1");
_registry.CreateChannel("conn-2");
// Act
_registry.Dispose();
// Assert - Count may not be accurate after dispose, but GetChannel should not work
// We need a separate test for post-dispose behavior
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
_registry.CreateChannel("conn-1");
// Act
var action = () =>
{
_registry.Dispose();
_registry.Dispose();
_registry.Dispose();
};
// Assert
action.Should().NotThrow();
}
#endregion
#region Concurrency Tests
[Fact]
public async Task ConcurrentOperations_ThreadSafe()
{
// Arrange
var tasks = new List<Task>();
var connectionCount = 100;
// Act - Create and remove channels concurrently
for (int i = 0; i < connectionCount; i++)
{
var id = $"conn-{i}";
tasks.Add(Task.Run(() => _registry.CreateChannel(id)));
}
await Task.WhenAll(tasks);
// Assert
_registry.Count.Should().Be(connectionCount);
_registry.ConnectionIds.Should().HaveCount(connectionCount);
}
#endregion
}

View File

@@ -0,0 +1,176 @@
namespace StellaOps.Router.Transport.InMemory.Tests;
/// <summary>
/// Unit tests for <see cref="InMemoryTransportOptions"/>.
/// </summary>
public sealed class InMemoryTransportOptionsTests
{
#region Default Values Tests
[Fact]
public void Constructor_DefaultTimeout_Is30Seconds()
{
// Arrange & Act
var options = new InMemoryTransportOptions();
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void Constructor_SimulatedLatency_IsZero()
{
// Arrange & Act
var options = new InMemoryTransportOptions();
// Assert
options.SimulatedLatency.Should().Be(TimeSpan.Zero);
}
[Fact]
public void Constructor_ChannelBufferSize_IsZero()
{
// Arrange & Act
var options = new InMemoryTransportOptions();
// Assert
options.ChannelBufferSize.Should().Be(0);
}
[Fact]
public void Constructor_HeartbeatInterval_Is10Seconds()
{
// Arrange & Act
var options = new InMemoryTransportOptions();
// Assert
options.HeartbeatInterval.Should().Be(TimeSpan.FromSeconds(10));
}
[Fact]
public void Constructor_HeartbeatTimeout_Is30Seconds()
{
// Arrange & Act
var options = new InMemoryTransportOptions();
// Assert
options.HeartbeatTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
#endregion
#region Property Assignment Tests
[Fact]
public void DefaultTimeout_CanBeSet()
{
// Arrange
var options = new InMemoryTransportOptions();
// Act
options.DefaultTimeout = TimeSpan.FromMinutes(5);
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
}
[Fact]
public void SimulatedLatency_CanBeSet()
{
// Arrange
var options = new InMemoryTransportOptions();
// Act
options.SimulatedLatency = TimeSpan.FromMilliseconds(100);
// Assert
options.SimulatedLatency.Should().Be(TimeSpan.FromMilliseconds(100));
}
[Theory]
[InlineData(0)]
[InlineData(100)]
[InlineData(1000)]
[InlineData(10000)]
public void ChannelBufferSize_CanBeSet(int bufferSize)
{
// Arrange
var options = new InMemoryTransportOptions();
// Act
options.ChannelBufferSize = bufferSize;
// Assert
options.ChannelBufferSize.Should().Be(bufferSize);
}
[Fact]
public void HeartbeatInterval_CanBeSet()
{
// Arrange
var options = new InMemoryTransportOptions();
// Act
options.HeartbeatInterval = TimeSpan.FromSeconds(5);
// Assert
options.HeartbeatInterval.Should().Be(TimeSpan.FromSeconds(5));
}
[Fact]
public void HeartbeatTimeout_CanBeSet()
{
// Arrange
var options = new InMemoryTransportOptions();
// Act
options.HeartbeatTimeout = TimeSpan.FromMinutes(1);
// Assert
options.HeartbeatTimeout.Should().Be(TimeSpan.FromMinutes(1));
}
#endregion
#region Typical Configuration Tests
[Fact]
public void TypicalConfiguration_DevelopmentEnvironment()
{
// Arrange & Act
var options = new InMemoryTransportOptions
{
DefaultTimeout = TimeSpan.FromMinutes(5), // Longer timeout for debugging
SimulatedLatency = TimeSpan.Zero, // Instant for development
ChannelBufferSize = 0, // Unbounded
HeartbeatInterval = TimeSpan.FromSeconds(30),
HeartbeatTimeout = TimeSpan.FromMinutes(5)
};
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
options.SimulatedLatency.Should().Be(TimeSpan.Zero);
options.ChannelBufferSize.Should().Be(0);
}
[Fact]
public void TypicalConfiguration_TestingWithSimulatedLatency()
{
// Arrange & Act
var options = new InMemoryTransportOptions
{
DefaultTimeout = TimeSpan.FromSeconds(60),
SimulatedLatency = TimeSpan.FromMilliseconds(50), // Simulate network latency
ChannelBufferSize = 100, // Bounded for testing backpressure
HeartbeatInterval = TimeSpan.FromSeconds(5),
HeartbeatTimeout = TimeSpan.FromSeconds(15)
};
// Assert
options.SimulatedLatency.Should().Be(TimeSpan.FromMilliseconds(50));
options.ChannelBufferSize.Should().Be(100);
options.HeartbeatTimeout.Should().BeGreaterThan(options.HeartbeatInterval);
}
#endregion
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Transport.InMemory.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for InMemory tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<!-- Test SDK packages come from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,436 @@
using RabbitMQ.Client;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Transport.RabbitMq.Tests;
/// <summary>
/// Unit tests for <see cref="RabbitMqFrameProtocol"/>.
/// </summary>
public sealed class RabbitMqFrameProtocolTests
{
#region ParseFrame Tests
[Fact]
public void ParseFrame_WithValidProperties_ReturnsFrame()
{
// Arrange
var body = new byte[] { 1, 2, 3, 4, 5 };
var properties = new StubBasicProperties
{
Type = "Request",
CorrelationId = "test-correlation-id"
};
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Request);
frame.CorrelationId.Should().Be("test-correlation-id");
frame.Payload.ToArray().Should().BeEquivalentTo(body);
}
[Fact]
public void ParseFrame_WithResponseType_ReturnsResponseFrame()
{
// Arrange
var body = new byte[] { 1, 2 };
var properties = new StubBasicProperties { Type = "Response", CorrelationId = "resp-123" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Response);
}
[Fact]
public void ParseFrame_WithHelloType_ReturnsHelloFrame()
{
// Arrange
var body = Array.Empty<byte>();
var properties = new StubBasicProperties { Type = "Hello", CorrelationId = "hello-123" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Hello);
}
[Fact]
public void ParseFrame_WithHeartbeatType_ReturnsHeartbeatFrame()
{
// Arrange
var body = Array.Empty<byte>();
var properties = new StubBasicProperties { Type = "Heartbeat", CorrelationId = "hb-123" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Heartbeat);
}
[Fact]
public void ParseFrame_WithCancelType_ReturnsCancelFrame()
{
// Arrange
var body = Array.Empty<byte>();
var properties = new StubBasicProperties { Type = "Cancel", CorrelationId = "cancel-123" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Cancel);
}
[Fact]
public void ParseFrame_WithNullType_DefaultsToRequest()
{
// Arrange
var body = new byte[] { 1 };
var properties = new StubBasicProperties { Type = null, CorrelationId = "test" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Request);
}
[Fact]
public void ParseFrame_WithEmptyType_DefaultsToRequest()
{
// Arrange
var body = new byte[] { 1 };
var properties = new StubBasicProperties { Type = "", CorrelationId = "test" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Request);
}
[Fact]
public void ParseFrame_WithInvalidType_DefaultsToRequest()
{
// Arrange
var body = new byte[] { 1 };
var properties = new StubBasicProperties { Type = "InvalidType", CorrelationId = "test" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Request);
}
[Fact]
public void ParseFrame_CaseInsensitive_ParsesType()
{
// Arrange
var body = new byte[] { 1 };
var properties = new StubBasicProperties { Type = "rEsPoNsE", CorrelationId = "test" };
// Act
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
// Assert
frame.Type.Should().Be(FrameType.Response);
}
#endregion
#region CreateProperties Tests
[Fact]
public void CreateProperties_WithFrame_SetsTypeProperty()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Response,
CorrelationId = "test-123",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
// Assert
properties.Type.Should().Be("Response");
}
[Fact]
public void CreateProperties_WithCorrelationId_SetsCorrelationId()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "my-correlation-id",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
// Assert
properties.CorrelationId.Should().Be("my-correlation-id");
}
[Fact]
public void CreateProperties_WithReplyTo_SetsReplyTo()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, "my-reply-queue");
// Assert
properties.ReplyTo.Should().Be("my-reply-queue");
}
[Fact]
public void CreateProperties_WithNullReplyTo_DoesNotSetReplyTo()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
// Assert
properties.ReplyTo.Should().BeNullOrEmpty();
}
[Fact]
public void CreateProperties_WithTimeout_SetsExpiration()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
var timeout = TimeSpan.FromSeconds(30);
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null, timeout);
// Assert
properties.Expiration.Should().Be("30000");
}
[Fact]
public void CreateProperties_WithoutTimeout_DoesNotSetExpiration()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null, null);
// Assert
properties.Expiration.Should().BeNullOrEmpty();
}
[Fact]
public void CreateProperties_SetsTimestamp()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
var beforeTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
var afterTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Assert
properties.Timestamp.UnixTime.Should().BeInRange(beforeTimestamp, afterTimestamp);
}
[Fact]
public void CreateProperties_SetsTransientDeliveryMode()
{
// Arrange
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
// Act
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
// Assert
properties.DeliveryMode.Should().Be(DeliveryModes.Transient);
}
#endregion
#region ExtractConnectionId Tests
[Fact]
public void ExtractConnectionId_WithReplyTo_ExtractsFromQueueName()
{
// Arrange
var properties = new StubBasicProperties
{
Type = "Hello",
CorrelationId = "test",
ReplyTo = "stella.svc.instance-123"
};
// Act
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
// Assert
connectionId.Should().Be("rmq-instance-123");
}
[Fact]
public void ExtractConnectionId_WithSimpleReplyTo_PrefixesWithRmq()
{
// Arrange
var properties = new StubBasicProperties
{
Type = "Hello",
CorrelationId = "test",
ReplyTo = "simple-queue"
};
// Act
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
// Assert
connectionId.Should().Be("rmq-simple-queue");
}
[Fact]
public void ExtractConnectionId_WithoutReplyTo_UsesCorrelationId()
{
// Arrange
var properties = new StubBasicProperties
{
Type = "Hello",
CorrelationId = "abcd1234567890efgh",
ReplyTo = null
};
// Act
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
// Assert
connectionId.Should().StartWith("rmq-");
connectionId.Should().Contain("abcd1234567890ef");
}
[Fact]
public void ExtractConnectionId_WithShortCorrelationId_UsesEntireId()
{
// Arrange
var properties = new StubBasicProperties
{
Type = "Hello",
CorrelationId = "short",
ReplyTo = null
};
// Act
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
// Assert
connectionId.Should().Be("rmq-short");
}
[Fact]
public void ExtractConnectionId_WithNoIdentifiers_GeneratesGuid()
{
// Arrange
var properties = new StubBasicProperties
{
Type = "Hello",
CorrelationId = null,
ReplyTo = null
};
// Act
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
// Assert
connectionId.Should().StartWith("rmq-");
connectionId.Length.Should().Be(32);
}
#endregion
#region Stub Implementation
/// <summary>
/// Stub implementation of IReadOnlyBasicProperties for testing.
/// </summary>
private sealed class StubBasicProperties : IReadOnlyBasicProperties
{
public string? AppId { get; init; }
public string? ClusterId { get; init; }
public string? ContentEncoding { get; init; }
public string? ContentType { get; init; }
public string? CorrelationId { get; init; }
public DeliveryModes DeliveryMode { get; init; }
public string? Expiration { get; init; }
public IDictionary<string, object?>? Headers { get; init; }
public string? MessageId { get; init; }
public bool Persistent { get; init; }
public byte Priority { get; init; }
public string? ReplyTo { get; init; }
public PublicationAddress? ReplyToAddress { get; init; }
public AmqpTimestamp Timestamp { get; init; }
public string? Type { get; init; }
public string? UserId { get; init; }
public bool IsAppIdPresent() => AppId != null;
public bool IsClusterIdPresent() => ClusterId != null;
public bool IsContentEncodingPresent() => ContentEncoding != null;
public bool IsContentTypePresent() => ContentType != null;
public bool IsCorrelationIdPresent() => CorrelationId != null;
public bool IsDeliveryModePresent() => true;
public bool IsExpirationPresent() => Expiration != null;
public bool IsHeadersPresent() => Headers != null;
public bool IsMessageIdPresent() => MessageId != null;
public bool IsPriorityPresent() => true;
public bool IsReplyToPresent() => ReplyTo != null;
public bool IsTimestampPresent() => true;
public bool IsTypePresent() => Type != null;
public bool IsUserIdPresent() => UserId != null;
}
#endregion
}

View File

@@ -0,0 +1,248 @@
namespace StellaOps.Router.Transport.RabbitMq.Tests;
/// <summary>
/// Unit tests for <see cref="RabbitMqTransportOptions"/>.
/// </summary>
public sealed class RabbitMqTransportOptionsTests
{
[Fact]
public void DefaultOptions_HostName_IsLocalhost()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.HostName.Should().Be("localhost");
}
[Fact]
public void DefaultOptions_Port_Is5672()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.Port.Should().Be(5672);
}
[Fact]
public void DefaultOptions_VirtualHost_IsRoot()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.VirtualHost.Should().Be("/");
}
[Fact]
public void DefaultOptions_UserName_IsGuest()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.UserName.Should().Be("guest");
}
[Fact]
public void DefaultOptions_Password_IsGuest()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.Password.Should().Be("guest");
}
[Fact]
public void DefaultOptions_UseSsl_IsFalse()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.UseSsl.Should().BeFalse();
}
[Fact]
public void DefaultOptions_SslCertPath_IsNull()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.SslCertPath.Should().BeNull();
}
[Fact]
public void DefaultOptions_DurableQueues_IsFalse()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.DurableQueues.Should().BeFalse();
}
[Fact]
public void DefaultOptions_AutoDeleteQueues_IsTrue()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.AutoDeleteQueues.Should().BeTrue();
}
[Fact]
public void DefaultOptions_PrefetchCount_Is10()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.PrefetchCount.Should().Be(10);
}
[Fact]
public void DefaultOptions_ExchangePrefix_IsStellaRouter()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.ExchangePrefix.Should().Be("stella.router");
}
[Fact]
public void DefaultOptions_QueuePrefix_IsStella()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.QueuePrefix.Should().Be("stella");
}
[Fact]
public void RequestExchange_UsesExchangePrefix()
{
// Arrange
var options = new RabbitMqTransportOptions
{
ExchangePrefix = "custom.prefix"
};
// Act & Assert
options.RequestExchange.Should().Be("custom.prefix.requests");
}
[Fact]
public void ResponseExchange_UsesExchangePrefix()
{
// Arrange
var options = new RabbitMqTransportOptions
{
ExchangePrefix = "custom.prefix"
};
// Act & Assert
options.ResponseExchange.Should().Be("custom.prefix.responses");
}
[Fact]
public void DefaultOptions_NodeId_IsNull()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.NodeId.Should().BeNull();
}
[Fact]
public void DefaultOptions_InstanceId_IsNull()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.InstanceId.Should().BeNull();
}
[Fact]
public void DefaultOptions_AutomaticRecoveryEnabled_IsTrue()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.AutomaticRecoveryEnabled.Should().BeTrue();
}
[Fact]
public void DefaultOptions_NetworkRecoveryInterval_Is5Seconds()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.NetworkRecoveryInterval.Should().Be(TimeSpan.FromSeconds(5));
}
[Fact]
public void DefaultOptions_DefaultTimeout_Is30Seconds()
{
// Arrange & Act
var options = new RabbitMqTransportOptions();
// Assert
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void Options_CanBeCustomized()
{
// Arrange & Act
var options = new RabbitMqTransportOptions
{
HostName = "rabbitmq.example.com",
Port = 5673,
VirtualHost = "/vhost",
UserName = "admin",
Password = "secret",
UseSsl = true,
SslCertPath = "/path/to/cert.pem",
DurableQueues = true,
AutoDeleteQueues = false,
PrefetchCount = 50,
ExchangePrefix = "myapp",
QueuePrefix = "myqueues",
NodeId = "node-1",
InstanceId = "instance-1",
AutomaticRecoveryEnabled = false,
NetworkRecoveryInterval = TimeSpan.FromSeconds(10),
DefaultTimeout = TimeSpan.FromMinutes(1)
};
// Assert
options.HostName.Should().Be("rabbitmq.example.com");
options.Port.Should().Be(5673);
options.VirtualHost.Should().Be("/vhost");
options.UserName.Should().Be("admin");
options.Password.Should().Be("secret");
options.UseSsl.Should().BeTrue();
options.SslCertPath.Should().Be("/path/to/cert.pem");
options.DurableQueues.Should().BeTrue();
options.AutoDeleteQueues.Should().BeFalse();
options.PrefetchCount.Should().Be(50);
options.ExchangePrefix.Should().Be("myapp");
options.QueuePrefix.Should().Be("myqueues");
options.NodeId.Should().Be("node-1");
options.InstanceId.Should().Be("instance-1");
options.AutomaticRecoveryEnabled.Should().BeFalse();
options.NetworkRecoveryInterval.Should().Be(TimeSpan.FromSeconds(10));
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Transport.RabbitMq.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<!-- Test SDK packages come from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj" />
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
</ItemGroup>
</Project>