Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly.
- Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified.
- Created tests for ConfigValidationResult to check success and error scenarios.
- Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig.
- Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport.
- Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -0,0 +1,185 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Microservice;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Tests for EndpointDiscoveryService - verifies integration of discovery + YAML loading + merging.
/// </summary>
public class EndpointDiscoveryServiceTests
{
private readonly Mock<IEndpointDiscoveryProvider> _discoveryProviderMock;
private readonly Mock<IMicroserviceYamlLoader> _yamlLoaderMock;
private readonly Mock<IEndpointOverrideMerger> _mergerMock;
private readonly ILogger<EndpointDiscoveryService> _logger;
private readonly EndpointDiscoveryService _service;
public EndpointDiscoveryServiceTests()
{
_discoveryProviderMock = new Mock<IEndpointDiscoveryProvider>();
_yamlLoaderMock = new Mock<IMicroserviceYamlLoader>();
_mergerMock = new Mock<IEndpointOverrideMerger>();
_logger = NullLogger<EndpointDiscoveryService>.Instance;
_service = new EndpointDiscoveryService(
_discoveryProviderMock.Object,
_yamlLoaderMock.Object,
_mergerMock.Object,
_logger);
}
[Fact]
public void DiscoverEndpoints_CallsDiscoveryProvider()
{
var codeEndpoints = new List<EndpointDescriptor>();
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_mergerMock
.Setup(x => x.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), It.IsAny<MicroserviceYamlConfig?>()))
.Returns(codeEndpoints);
_service.DiscoverEndpoints();
_discoveryProviderMock.Verify(x => x.DiscoverEndpoints(), Times.Once);
}
[Fact]
public void DiscoverEndpoints_CallsYamlLoader()
{
var codeEndpoints = new List<EndpointDescriptor>();
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_mergerMock
.Setup(x => x.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), It.IsAny<MicroserviceYamlConfig?>()))
.Returns(codeEndpoints);
_service.DiscoverEndpoints();
_yamlLoaderMock.Verify(x => x.Load(), Times.Once);
}
[Fact]
public void DiscoverEndpoints_PassesCodeEndpointsAndYamlConfigToMerger()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test")
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig { Method = "GET", Path = "/api/test" }
]
};
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_yamlLoaderMock
.Setup(x => x.Load())
.Returns(yamlConfig);
_mergerMock
.Setup(x => x.Merge(codeEndpoints, yamlConfig))
.Returns(codeEndpoints);
_service.DiscoverEndpoints();
_mergerMock.Verify(x => x.Merge(codeEndpoints, yamlConfig), Times.Once);
}
[Fact]
public void DiscoverEndpoints_ReturnsMergedEndpoints()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test", TimeSpan.FromSeconds(10))
};
var mergedEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test", TimeSpan.FromMinutes(5))
};
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_mergerMock
.Setup(x => x.Merge(It.IsAny<IReadOnlyList<EndpointDescriptor>>(), It.IsAny<MicroserviceYamlConfig?>()))
.Returns(mergedEndpoints);
var result = _service.DiscoverEndpoints();
result.Should().BeSameAs(mergedEndpoints);
}
[Fact]
public void DiscoverEndpoints_ContinuesWithNullYamlConfig_WhenLoaderReturnsNull()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test")
};
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_yamlLoaderMock
.Setup(x => x.Load())
.Returns((MicroserviceYamlConfig?)null);
_mergerMock
.Setup(x => x.Merge(codeEndpoints, null))
.Returns(codeEndpoints);
var result = _service.DiscoverEndpoints();
_mergerMock.Verify(x => x.Merge(codeEndpoints, null), Times.Once);
result.Should().BeSameAs(codeEndpoints);
}
[Fact]
public void DiscoverEndpoints_ContinuesWithNullYamlConfig_WhenLoaderThrows()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test")
};
_discoveryProviderMock
.Setup(x => x.DiscoverEndpoints())
.Returns(codeEndpoints);
_yamlLoaderMock
.Setup(x => x.Load())
.Throws(new Exception("YAML parsing failed"));
_mergerMock
.Setup(x => x.Merge(codeEndpoints, null))
.Returns(codeEndpoints);
var result = _service.DiscoverEndpoints();
// Should not throw, should continue with null config
_mergerMock.Verify(x => x.Merge(codeEndpoints, null), Times.Once);
result.Should().BeSameAs(codeEndpoints);
}
private static EndpointDescriptor CreateEndpoint(
string method,
string path,
TimeSpan? timeout = null)
{
return new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = method,
Path = path,
DefaultTimeout = timeout ?? TimeSpan.FromSeconds(30)
};
}
}

View File

@@ -0,0 +1,382 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Microservice;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Tests for EndpointOverrideMerger - verifies merge logic and precedence.
/// </summary>
public class EndpointOverrideMergerTests
{
private readonly EndpointOverrideMerger _merger;
private readonly Mock<ILogger<EndpointOverrideMerger>> _loggerMock;
public EndpointOverrideMergerTests()
{
_loggerMock = new Mock<ILogger<EndpointOverrideMerger>>();
_merger = new EndpointOverrideMerger(_loggerMock.Object);
}
[Fact]
public void Merge_WithNullYamlConfig_ReturnsCodeEndpointsUnchanged()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test", TimeSpan.FromSeconds(30))
};
var result = _merger.Merge(codeEndpoints, null);
result.Should().BeEquivalentTo(codeEndpoints);
}
[Fact]
public void Merge_WithEmptyYamlConfig_ReturnsCodeEndpointsUnchanged()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig { Endpoints = [] };
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().BeEquivalentTo(codeEndpoints);
}
[Fact]
public void Merge_OverridesTimeout_WhenYamlSpecifiesTimeout()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("POST", "/api/generate", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "POST",
Path = "/api/generate",
DefaultTimeout = "5m"
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
}
[Fact]
public void Merge_OverridesStreaming_WhenYamlSpecifiesStreaming()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/data", TimeSpan.FromSeconds(30), supportsStreaming: false)
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/api/data",
SupportsStreaming = true
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].SupportsStreaming.Should().BeTrue();
}
[Fact]
public void Merge_OverridesClaims_WhenYamlSpecifiesClaims()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("DELETE", "/api/users/{id}", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "DELETE",
Path = "/api/users/{id}",
RequiringClaims =
[
new ClaimRequirementConfig { Type = "role", Value = "admin" }
]
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].RequiringClaims.Should().HaveCount(1);
result[0].RequiringClaims![0].Type.Should().Be("role");
result[0].RequiringClaims[0].Value.Should().Be("admin");
}
[Fact]
public void Merge_PreservesCodeDefaults_WhenYamlDoesNotOverride()
{
var originalTimeout = TimeSpan.FromSeconds(45);
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/test", originalTimeout, supportsStreaming: true)
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/api/test"
// No overrides specified
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].DefaultTimeout.Should().Be(originalTimeout);
result[0].SupportsStreaming.Should().BeTrue();
}
[Fact]
public void Merge_MatchesCaseInsensitively()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/Test", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "get", // lowercase
Path = "/API/TEST", // uppercase
DefaultTimeout = "1m"
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
}
[Fact]
public void Merge_LeavesUnmatchedEndpointsUnchanged()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/one", TimeSpan.FromSeconds(10)),
CreateEndpoint("POST", "/api/two", TimeSpan.FromSeconds(20)),
CreateEndpoint("PUT", "/api/three", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "POST",
Path = "/api/two",
DefaultTimeout = "5m"
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(3);
result[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(10)); // unchanged
result[1].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5)); // overridden
result[2].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30)); // unchanged
}
[Fact]
public void Merge_LogsWarning_WhenYamlOverrideDoesNotMatchAnyEndpoint()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/existing", TimeSpan.FromSeconds(30))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "POST",
Path = "/api/nonexistent",
DefaultTimeout = "5m"
}
]
};
_merger.Merge(codeEndpoints, yamlConfig);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("does not match any code endpoint")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void Merge_AppliesMultipleOverrides()
{
var codeEndpoints = new List<EndpointDescriptor>
{
CreateEndpoint("GET", "/api/one", TimeSpan.FromSeconds(10)),
CreateEndpoint("POST", "/api/two", TimeSpan.FromSeconds(20))
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/api/one",
DefaultTimeout = "1m"
},
new EndpointOverrideConfig
{
Method = "POST",
Path = "/api/two",
DefaultTimeout = "2m"
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(2);
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
result[1].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(2));
}
[Fact]
public void Merge_PreservesOriginalEndpointProperties()
{
var codeEndpoints = new List<EndpointDescriptor>
{
new()
{
ServiceName = "test-service",
Version = "2.0.0",
Method = "GET",
Path = "/api/test",
DefaultTimeout = TimeSpan.FromSeconds(30),
SupportsStreaming = false,
HandlerType = typeof(object)
}
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/api/test",
DefaultTimeout = "1m"
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result.Should().HaveCount(1);
result[0].ServiceName.Should().Be("test-service");
result[0].Version.Should().Be("2.0.0");
result[0].Method.Should().Be("GET");
result[0].Path.Should().Be("/api/test");
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
result[0].HandlerType.Should().Be(typeof(object));
}
[Fact]
public void Merge_YamlOverridesCodeClaims_Completely()
{
var codeEndpoints = new List<EndpointDescriptor>
{
new()
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/test",
DefaultTimeout = TimeSpan.FromSeconds(30),
RequiringClaims =
[
new ClaimRequirement { Type = "original", Value = "claim" }
]
}
};
var yamlConfig = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/api/test",
RequiringClaims =
[
new ClaimRequirementConfig { Type = "new", Value = "claim1" },
new ClaimRequirementConfig { Type = "new", Value = "claim2" }
]
}
]
};
var result = _merger.Merge(codeEndpoints, yamlConfig);
result[0].RequiringClaims.Should().HaveCount(2);
result[0].RequiringClaims!.All(c => c.Type == "new").Should().BeTrue();
}
private static EndpointDescriptor CreateEndpoint(
string method,
string path,
TimeSpan timeout,
bool supportsStreaming = false)
{
return new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = method,
Path = path,
DefaultTimeout = timeout,
SupportsStreaming = supportsStreaming
};
}
}

View File

@@ -0,0 +1,169 @@
using FluentAssertions;
using StellaOps.Microservice;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Microservice.Tests;
public class EndpointRegistryTests
{
private static EndpointDescriptor CreateEndpoint(string method, string path, Type? handlerType = null)
{
return new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = method,
Path = path,
HandlerType = handlerType
};
}
[Fact]
public void TryMatch_ExactMatch_ReturnsEndpoint()
{
var registry = new EndpointRegistry();
var endpoint = CreateEndpoint("GET", "/api/users");
registry.Register(endpoint);
var result = registry.TryMatch("GET", "/api/users", out var match);
result.Should().BeTrue();
match.Should().NotBeNull();
match!.Endpoint.Should().Be(endpoint);
match.PathParameters.Should().BeEmpty();
}
[Fact]
public void TryMatch_MethodMismatch_ReturnsFalse()
{
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
var result = registry.TryMatch("POST", "/api/users", out var match);
result.Should().BeFalse();
match.Should().BeNull();
}
[Fact]
public void TryMatch_PathMismatch_ReturnsFalse()
{
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
var result = registry.TryMatch("GET", "/api/products", out var match);
result.Should().BeFalse();
match.Should().BeNull();
}
[Fact]
public void TryMatch_WithPathParameter_ExtractsParameter()
{
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
var result = registry.TryMatch("GET", "/api/users/123", out var match);
result.Should().BeTrue();
match.Should().NotBeNull();
match!.PathParameters.Should().ContainKey("id");
match.PathParameters["id"].Should().Be("123");
}
[Fact]
public void TryMatch_MethodCaseInsensitive_ReturnsMatch()
{
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
var result = registry.TryMatch("get", "/api/users", out var match);
result.Should().BeTrue();
match.Should().NotBeNull();
}
[Fact]
public void TryMatch_PathCaseInsensitive_ReturnsMatch()
{
var registry = new EndpointRegistry();
registry.Register(CreateEndpoint("GET", "/api/users"));
var result = registry.TryMatch("GET", "/API/USERS", out var match);
result.Should().BeTrue();
match.Should().NotBeNull();
}
[Fact]
public void RegisterAll_MultipeEndpoints_AllRegistered()
{
var registry = new EndpointRegistry();
var endpoints = new[]
{
CreateEndpoint("GET", "/api/users"),
CreateEndpoint("POST", "/api/users"),
CreateEndpoint("GET", "/api/users/{id}")
};
registry.RegisterAll(endpoints);
registry.GetAllEndpoints().Should().HaveCount(3);
}
[Fact]
public void GetAllEndpoints_ReturnsAllRegistered()
{
var registry = new EndpointRegistry();
var endpoint1 = CreateEndpoint("GET", "/api/users");
var endpoint2 = CreateEndpoint("POST", "/api/users");
registry.Register(endpoint1);
registry.Register(endpoint2);
var all = registry.GetAllEndpoints();
all.Should().HaveCount(2);
all.Should().Contain(endpoint1);
all.Should().Contain(endpoint2);
}
[Fact]
public void TryMatch_FirstMatchWins_WhenMultiplePossible()
{
var registry = new EndpointRegistry();
var endpoint1 = CreateEndpoint("GET", "/api/users/{id}");
var endpoint2 = CreateEndpoint("GET", "/api/{resource}/{id}");
registry.Register(endpoint1);
registry.Register(endpoint2);
var result = registry.TryMatch("GET", "/api/users/123", out var match);
result.Should().BeTrue();
match.Should().NotBeNull();
// First registered endpoint should match
match!.Endpoint.Should().Be(endpoint1);
}
[Fact]
public void TryMatch_EmptyRegistry_ReturnsFalse()
{
var registry = new EndpointRegistry();
var result = registry.TryMatch("GET", "/api/users", out var match);
result.Should().BeFalse();
match.Should().BeNull();
}
[Fact]
public void Constructor_CaseSensitive_RespectsSetting()
{
var registry = new EndpointRegistry(caseInsensitive: false);
registry.Register(CreateEndpoint("GET", "/api/users"));
var result = registry.TryMatch("GET", "/API/USERS", out var match);
result.Should().BeFalse();
}
}

View File

@@ -0,0 +1,144 @@
using FluentAssertions;
using StellaOps.Microservice;
using Xunit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Tests for MicroserviceYamlConfig and EndpointOverrideConfig classes.
/// </summary>
public class MicroserviceYamlConfigTests
{
[Fact]
public void MicroserviceYamlConfig_DefaultsToEmptyEndpoints()
{
var config = new MicroserviceYamlConfig();
config.Endpoints.Should().NotBeNull();
config.Endpoints.Should().BeEmpty();
}
[Fact]
public void EndpointOverrideConfig_DefaultsToEmptyStrings()
{
var config = new EndpointOverrideConfig();
config.Method.Should().Be(string.Empty);
config.Path.Should().Be(string.Empty);
config.DefaultTimeout.Should().BeNull();
config.SupportsStreaming.Should().BeNull();
config.RequiringClaims.Should().BeNull();
}
[Theory]
[InlineData("30s", 30)]
[InlineData("60s", 60)]
[InlineData("1s", 1)]
[InlineData("120S", 120)] // Case insensitive
public void GetDefaultTimeoutAsTimeSpan_ParsesSeconds(string input, int expectedSeconds)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
}
[Theory]
[InlineData("5m", 5)]
[InlineData("10m", 10)]
[InlineData("1m", 1)]
[InlineData("30M", 30)] // Case insensitive
public void GetDefaultTimeoutAsTimeSpan_ParsesMinutes(string input, int expectedMinutes)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().Be(TimeSpan.FromMinutes(expectedMinutes));
}
[Theory]
[InlineData("1h", 1)]
[InlineData("2h", 2)]
[InlineData("24h", 24)]
[InlineData("1H", 1)] // Case insensitive
public void GetDefaultTimeoutAsTimeSpan_ParsesHours(string input, int expectedHours)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().Be(TimeSpan.FromHours(expectedHours));
}
[Theory]
[InlineData("00:00:30", 30)]
[InlineData("00:05:00", 300)]
[InlineData("01:00:00", 3600)]
[InlineData("00:01:30", 90)]
public void GetDefaultTimeoutAsTimeSpan_ParsesTimeSpanFormat(string input, int expectedSeconds)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void GetDefaultTimeoutAsTimeSpan_ReturnsNullForEmptyValues(string? input)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().BeNull();
}
[Theory]
[InlineData("invalid")]
[InlineData("abc")]
[InlineData("30x")]
public void GetDefaultTimeoutAsTimeSpan_ReturnsNullForInvalidFormats(string input)
{
var config = new EndpointOverrideConfig { DefaultTimeout = input };
var result = config.GetDefaultTimeoutAsTimeSpan();
result.Should().BeNull();
}
[Fact]
public void ClaimRequirementConfig_ToClaimRequirement_ConvertsCorrectly()
{
var config = new ClaimRequirementConfig
{
Type = "role",
Value = "admin"
};
var result = config.ToClaimRequirement();
result.Type.Should().Be("role");
result.Value.Should().Be("admin");
}
[Fact]
public void ClaimRequirementConfig_ToClaimRequirement_HandlesNullValue()
{
var config = new ClaimRequirementConfig
{
Type = "authenticated",
Value = null
};
var result = config.ToClaimRequirement();
result.Type.Should().Be("authenticated");
result.Value.Should().BeNull();
}
}

View File

@@ -0,0 +1,289 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Microservice;
using Xunit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Tests for MicroserviceYamlLoader.
/// </summary>
public class MicroserviceYamlLoaderTests : IDisposable
{
private readonly string _tempDirectory;
private readonly ILogger<MicroserviceYamlLoader> _logger;
public MicroserviceYamlLoaderTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), $"MicroserviceYamlLoaderTests_{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDirectory);
_logger = NullLogger<MicroserviceYamlLoader>.Instance;
}
public void Dispose()
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, true);
}
}
[Fact]
public void Load_ReturnsNull_WhenConfigFilePathIsNull()
{
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = null
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().BeNull();
}
[Fact]
public void Load_ReturnsNull_WhenConfigFilePathIsEmpty()
{
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = ""
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().BeNull();
}
[Fact]
public void Load_ReturnsNull_WhenFileDoesNotExist()
{
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = Path.Combine(_tempDirectory, "nonexistent.yaml")
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().BeNull();
}
[Fact]
public void Load_ParsesValidYaml()
{
var yamlContent = """
endpoints:
- method: GET
path: /api/test
defaultTimeout: 30s
supportsStreaming: true
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
result!.Endpoints.Should().HaveCount(1);
result.Endpoints[0].Method.Should().Be("GET");
result.Endpoints[0].Path.Should().Be("/api/test");
result.Endpoints[0].DefaultTimeout.Should().Be("30s");
result.Endpoints[0].SupportsStreaming.Should().BeTrue();
}
[Fact]
public void Load_ParsesMultipleEndpoints()
{
var yamlContent = """
endpoints:
- method: GET
path: /api/one
defaultTimeout: 10s
- method: POST
path: /api/two
defaultTimeout: 5m
- method: DELETE
path: /api/three
defaultTimeout: 1h
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
result!.Endpoints.Should().HaveCount(3);
}
[Fact]
public void Load_ParsesClaimRequirements()
{
var yamlContent = """
endpoints:
- method: DELETE
path: /api/admin
requiringClaims:
- type: role
value: admin
- type: permission
value: delete
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
result!.Endpoints.Should().HaveCount(1);
result.Endpoints[0].RequiringClaims.Should().HaveCount(2);
result.Endpoints[0].RequiringClaims![0].Type.Should().Be("role");
result.Endpoints[0].RequiringClaims![0].Value.Should().Be("admin");
result.Endpoints[0].RequiringClaims![1].Type.Should().Be("permission");
result.Endpoints[0].RequiringClaims![1].Value.Should().Be("delete");
}
[Fact]
public void Load_HandlesEmptyEndpointsList()
{
var yamlContent = """
endpoints: []
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
result!.Endpoints.Should().BeEmpty();
}
[Fact]
public void Load_IgnoresUnknownProperties()
{
var yamlContent = """
unknownProperty: value
endpoints:
- method: GET
path: /api/test
unknownField: ignored
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
result!.Endpoints.Should().HaveCount(1);
}
[Fact]
public void Load_ThrowsOnInvalidYaml()
{
var yamlContent = """
endpoints:
- method: GET
path /api/test # missing colon
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = filePath
};
var loader = new MicroserviceYamlLoader(options, _logger);
Action act = () => loader.Load();
act.Should().Throw<Exception>();
}
[Fact]
public void Load_ResolvesRelativePath()
{
var yamlContent = """
endpoints:
- method: GET
path: /api/test
""";
var filePath = Path.Combine(_tempDirectory, "config.yaml");
File.WriteAllText(filePath, yamlContent);
// Save current directory and change to temp directory
var originalDirectory = Environment.CurrentDirectory;
try
{
Environment.CurrentDirectory = _tempDirectory;
var options = new StellaMicroserviceOptions
{
ServiceName = "test",
Version = "1.0.0",
Region = "us",
ConfigFilePath = "config.yaml" // relative path
};
var loader = new MicroserviceYamlLoader(options, _logger);
var result = loader.Load();
result.Should().NotBeNull();
}
finally
{
Environment.CurrentDirectory = originalDirectory;
}
}
}

View File

@@ -8,7 +8,11 @@
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -0,0 +1,192 @@
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Microservice;
using Xunit;
namespace StellaOps.Microservice.Tests;
public class TypedEndpointAdapterTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public record TestRequest(string Name, int Value);
public record TestResponse(string Message, bool Success);
public class TestTypedHandler : IStellaEndpoint<TestRequest, TestResponse>
{
public Task<TestResponse> HandleAsync(TestRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new TestResponse($"Hello, {request.Name}!", true));
}
}
public class TestNoRequestHandler : IStellaEndpoint<TestResponse>
{
public Task<TestResponse> HandleAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new TestResponse("No request needed", true));
}
}
public class TestRawHandler : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
return Task.FromResult(RawResponse.Ok("Raw response"));
}
}
[Fact]
public async Task Adapt_TypedWithRequest_DeserializesAndSerializes()
{
var handler = new TestTypedHandler();
var adapter = TypedEndpointAdapter.Adapt<TestRequest, TestResponse>(handler);
var request = new TestRequest("World", 42);
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions);
var context = new RawRequestContext
{
Method = "POST",
Path = "/test",
Body = new MemoryStream(requestBytes),
Headers = HeaderCollection.Empty
};
var response = await adapter(context, CancellationToken.None);
response.StatusCode.Should().Be(200);
response.Headers["Content-Type"].Should().Contain("application/json");
var responseBody = await ReadResponseBody(response);
var result = JsonSerializer.Deserialize<TestResponse>(responseBody, JsonOptions);
result.Should().NotBeNull();
result!.Message.Should().Be("Hello, World!");
result.Success.Should().BeTrue();
}
[Fact]
public async Task Adapt_TypedNoRequest_SerializesResponse()
{
var handler = new TestNoRequestHandler();
var adapter = TypedEndpointAdapter.Adapt<TestResponse>(handler);
var context = new RawRequestContext
{
Method = "GET",
Path = "/test",
Body = Stream.Null,
Headers = HeaderCollection.Empty
};
var response = await adapter(context, CancellationToken.None);
response.StatusCode.Should().Be(200);
var responseBody = await ReadResponseBody(response);
var result = JsonSerializer.Deserialize<TestResponse>(responseBody, JsonOptions);
result.Should().NotBeNull();
result!.Message.Should().Be("No request needed");
}
[Fact]
public async Task Adapt_RawHandler_PassesThroughDirectly()
{
var handler = new TestRawHandler();
var adapter = TypedEndpointAdapter.Adapt(handler);
var context = new RawRequestContext
{
Method = "GET",
Path = "/test",
Body = Stream.Null,
Headers = HeaderCollection.Empty
};
var response = await adapter(context, CancellationToken.None);
response.StatusCode.Should().Be(200);
}
[Fact]
public async Task Adapt_InvalidJson_ReturnsBadRequest()
{
var handler = new TestTypedHandler();
var adapter = TypedEndpointAdapter.Adapt<TestRequest, TestResponse>(handler);
var context = new RawRequestContext
{
Method = "POST",
Path = "/test",
Body = new MemoryStream(Encoding.UTF8.GetBytes("not valid json")),
Headers = HeaderCollection.Empty
};
var response = await adapter(context, CancellationToken.None);
response.StatusCode.Should().Be(400);
}
[Fact]
public async Task Adapt_EmptyBody_ReturnsBadRequest()
{
var handler = new TestTypedHandler();
var adapter = TypedEndpointAdapter.Adapt<TestRequest, TestResponse>(handler);
var context = new RawRequestContext
{
Method = "POST",
Path = "/test",
Body = new MemoryStream([]),
Headers = HeaderCollection.Empty
};
var response = await adapter(context, CancellationToken.None);
response.StatusCode.Should().Be(400);
}
[Fact]
public async Task Adapt_WithCancellation_PropagatesCancellation()
{
var handler = new CancellableHandler();
var adapter = TypedEndpointAdapter.Adapt<TestResponse>(handler);
using var cts = new CancellationTokenSource();
cts.Cancel();
var context = new RawRequestContext
{
Method = "GET",
Path = "/test",
Body = Stream.Null,
Headers = HeaderCollection.Empty
};
await Assert.ThrowsAsync<OperationCanceledException>(() =>
adapter(context, cts.Token));
}
private class CancellableHandler : IStellaEndpoint<TestResponse>
{
public Task<TestResponse> HandleAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new TestResponse("OK", true));
}
}
private static async Task<string> ReadResponseBody(RawResponse response)
{
if (response.Body == Stream.Null)
return string.Empty;
response.Body.Position = 0;
using var reader = new StreamReader(response.Body);
return await reader.ReadToEndAsync();
}
}