audit, advisories and doctors/setup work
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointOverrideMerger"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointOverrideMergerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_InvalidTimeout_LogsWarningAndKeepsDefault()
|
||||
{
|
||||
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
|
||||
var merger = new EndpointOverrideMerger(logger.Object);
|
||||
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "svc",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/ping",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
var config = new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointOverrideConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/ping",
|
||||
DefaultTimeout = "bogus"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var merged = merger.Merge([endpoint], config);
|
||||
|
||||
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(10));
|
||||
VerifyWarning(logger, "Invalid defaultTimeout");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_ValidTimeout_OverridesDefault()
|
||||
{
|
||||
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
|
||||
var merger = new EndpointOverrideMerger(logger.Object);
|
||||
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "svc",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/ping",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
var config = new MicroserviceYamlConfig
|
||||
{
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointOverrideConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/ping",
|
||||
DefaultTimeout = "30s"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var merged = merger.Merge([endpoint], config);
|
||||
|
||||
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
private static void VerifyWarning(Mock<ILogger<EndpointOverrideMerger>> logger, string messageContains)
|
||||
{
|
||||
logger.Verify(
|
||||
log => log.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((value, _) => value.ToString()!.Contains(messageContains, StringComparison.Ordinal)),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,34 @@ public sealed class HeaderCollectionTests
|
||||
empty.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_Add_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var empty = HeaderCollection.Empty;
|
||||
|
||||
// Act
|
||||
var action = () => empty.Add("X-Test", "value");
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_Set_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var empty = HeaderCollection.Empty;
|
||||
|
||||
// Act
|
||||
var action = () => empty.Set("X-Test", "value");
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Indexer Tests
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RequestDispatcher"/>.
|
||||
/// </summary>
|
||||
public sealed class RequestDispatcherTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DispatchAsync_MergesMultiValueHeaders()
|
||||
{
|
||||
var dispatcher = CreateDispatcher(typeof(MultiHeaderEndpoint));
|
||||
var request = new RequestFrame
|
||||
{
|
||||
RequestId = "req-1",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
|
||||
|
||||
response.Headers.Should().ContainKey("X-Test");
|
||||
response.Headers["X-Test"].Should().Be("one,two");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DispatchAsync_AllowsNonSeekableResponseBody()
|
||||
{
|
||||
var dispatcher = CreateDispatcher(typeof(NonSeekableEndpoint));
|
||||
var request = new RequestFrame
|
||||
{
|
||||
RequestId = "req-2",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
|
||||
|
||||
Encoding.UTF8.GetString(response.Payload.ToArray()).Should().Be("ok");
|
||||
}
|
||||
|
||||
private static RequestDispatcher CreateDispatcher(Type handlerType)
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "svc",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
HandlerType = handlerType
|
||||
});
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(handlerType);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return new RequestDispatcher(registry, provider, NullLogger<RequestDispatcher>.Instance);
|
||||
}
|
||||
|
||||
private sealed class MultiHeaderEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("X-Test", "one");
|
||||
headers.Add("X-Test", "two");
|
||||
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = headers,
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes("ok"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonSeekableEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection(),
|
||||
Body = new NonSeekableReadStream(Encoding.UTF8.GetBytes("ok"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonSeekableReadStream : Stream
|
||||
{
|
||||
private readonly MemoryStream _inner;
|
||||
|
||||
public NonSeekableReadStream(byte[] data)
|
||||
{
|
||||
_inner = new MemoryStream(data, writable: false);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
return _inner.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text;
|
||||
using StellaOps.Microservice.Endpoints;
|
||||
using StellaOps.Microservice.Validation;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for schema discovery endpoints.
|
||||
/// </summary>
|
||||
public sealed class SchemaDiscoveryEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SchemaDetailEndpoint_UsesQueryParametersDirection()
|
||||
{
|
||||
var registry = new FakeSchemaRegistry();
|
||||
var endpoint = new SchemaDetailEndpoint(registry);
|
||||
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
PathParameters = new Dictionary<string, string>
|
||||
{
|
||||
["method"] = "get",
|
||||
["path"] = "api/widgets"
|
||||
},
|
||||
QueryParameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["direction"] = "response"
|
||||
},
|
||||
Headers = new HeaderCollection()
|
||||
};
|
||||
|
||||
var response = await endpoint.HandleAsync(context, CancellationToken.None);
|
||||
|
||||
response.StatusCode.Should().Be(200);
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(FakeSchemaRegistry.ResponseSchema);
|
||||
}
|
||||
|
||||
private sealed class FakeSchemaRegistry : ISchemaRegistry
|
||||
{
|
||||
public const string RequestSchema = "{\"type\":\"object\",\"title\":\"request\"}";
|
||||
public const string ResponseSchema = "{\"type\":\"object\",\"title\":\"response\"}";
|
||||
|
||||
public Json.Schema.JsonSchema? GetRequestSchema(string method, string path) => null;
|
||||
|
||||
public Json.Schema.JsonSchema? GetResponseSchema(string method, string path) => null;
|
||||
|
||||
public string? GetSchemaText(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
return direction == SchemaDirection.Response ? ResponseSchema : RequestSchema;
|
||||
}
|
||||
|
||||
public string? GetSchemaETag(string method, string path, SchemaDirection direction) => null;
|
||||
|
||||
public bool HasSchema(string method, string path, SchemaDirection direction)
|
||||
{
|
||||
return method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Equals("/api/widgets", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas() => [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Microservice.Streaming;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for streaming request/response streams.
|
||||
/// </summary>
|
||||
public sealed class StreamingStreamsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StreamingRequestBodyStream_ReadsChunksInOrder()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2], SequenceNumber = 0 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [3, 4], SequenceNumber = 1, EndOfStream = true });
|
||||
channel.Writer.Complete();
|
||||
|
||||
var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
var buffer = new byte[8];
|
||||
var total = 0;
|
||||
int read;
|
||||
|
||||
while ((read = await stream.ReadAsync(buffer.AsMemory(total), CancellationToken.None)) > 0)
|
||||
{
|
||||
total += read;
|
||||
}
|
||||
|
||||
buffer.Take(total).Should().BeEquivalentTo(new byte[] { 1, 2, 3, 4 }, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StreamingResponseBodyStream_WritesChunksAndCompletes()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize: 2, cancellationToken: CancellationToken.None);
|
||||
|
||||
await stream.WriteAsync(new byte[] { 1, 2, 3 }, 0, 3, CancellationToken.None);
|
||||
await stream.CompleteAsync();
|
||||
|
||||
var chunks = new List<StreamChunk>();
|
||||
while (await channel.Reader.WaitToReadAsync())
|
||||
{
|
||||
while (channel.Reader.TryRead(out var chunk))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
if (chunk.EndOfStream)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks.Should().NotBeEmpty();
|
||||
chunks[^1].EndOfStream.Should().BeTrue();
|
||||
|
||||
var data = chunks.Where(c => c.Data.Length > 0).SelectMany(c => c.Data).ToArray();
|
||||
data.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }, options => options.WithStrictOrdering());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TypedEndpointAdapter"/>.
|
||||
/// </summary>
|
||||
public sealed class TypedEndpointAdapterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_TypedEndpoint_AllowsNonSeekableBody()
|
||||
{
|
||||
var handler = TypedEndpointAdapter.Adapt<EchoRequest, EchoResponse>(new EchoEndpoint());
|
||||
var payload = Encoding.UTF8.GetBytes("{\"name\":\"Ada\"}");
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Body = new NonSeekableReadStream(payload)
|
||||
};
|
||||
|
||||
var response = await handler(context, CancellationToken.None);
|
||||
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Contain("\"name\":\"Ada\"");
|
||||
}
|
||||
|
||||
private sealed record EchoRequest(string Name);
|
||||
|
||||
private sealed record EchoResponse(string Name);
|
||||
|
||||
private sealed class EchoEndpoint : IStellaEndpoint<EchoRequest, EchoResponse>
|
||||
{
|
||||
public Task<EchoResponse> HandleAsync(EchoRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new EchoResponse(request.Name));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonSeekableReadStream : Stream
|
||||
{
|
||||
private readonly MemoryStream _inner;
|
||||
|
||||
public NonSeekableReadStream(byte[] data)
|
||||
{
|
||||
_inner = new MemoryStream(data, writable: false);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
return _inner.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user