Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,210 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
using System.Text;
namespace StellaOps.Concelier.Connector.Common.Testing;
/// <summary>
/// Deterministic HTTP handler used by tests to supply canned responses keyed by request URI and method.
/// Tracks requests for assertions and supports fallbacks/exceptions.
/// </summary>
public sealed class CannedHttpMessageHandler : HttpMessageHandler
{
private readonly ConcurrentDictionary<RequestKey, ConcurrentQueue<Func<HttpRequestMessage, HttpResponseMessage>>> _responses =
new(RequestKeyComparer.Instance);
private readonly ConcurrentQueue<CannedRequestRecord> _requests = new();
private Func<HttpRequestMessage, HttpResponseMessage>? _fallback;
/// <summary>
/// Recorded requests in arrival order.
/// </summary>
public IReadOnlyCollection<CannedRequestRecord> Requests => _requests.ToArray();
/// <summary>
/// Registers a canned response for a GET request to <paramref name="requestUri"/>.
/// </summary>
public void AddResponse(Uri requestUri, Func<HttpResponseMessage> factory)
=> AddResponse(HttpMethod.Get, requestUri, _ => factory());
/// <summary>
/// Registers a canned response for the specified method and URI.
/// </summary>
public void AddResponse(HttpMethod method, Uri requestUri, Func<HttpResponseMessage> factory)
=> AddResponse(method, requestUri, _ => factory());
/// <summary>
/// Registers a canned response using the full request context.
/// </summary>
public void AddResponse(HttpMethod method, Uri requestUri, Func<HttpRequestMessage, HttpResponseMessage> factory)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(requestUri);
ArgumentNullException.ThrowIfNull(factory);
var key = new RequestKey(method, requestUri);
var queue = _responses.GetOrAdd(key, static _ => new ConcurrentQueue<Func<HttpRequestMessage, HttpResponseMessage>>());
queue.Enqueue(factory);
}
/// <summary>
/// Registers an exception to be thrown for the specified request.
/// </summary>
public void AddException(HttpMethod method, Uri requestUri, Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
AddResponse(method, requestUri, _ => throw exception);
}
/// <summary>
/// Registers a fallback used when no specific response is queued for a request.
/// </summary>
public void SetFallback(Func<HttpRequestMessage, HttpResponseMessage> fallback)
{
ArgumentNullException.ThrowIfNull(fallback);
_fallback = fallback;
}
/// <summary>
/// Clears registered responses and captured requests.
/// </summary>
public void Clear()
{
_responses.Clear();
while (_requests.TryDequeue(out _))
{
}
_fallback = null;
}
/// <summary>
/// Throws if any responses remain queued.
/// </summary>
public void AssertNoPendingResponses()
{
foreach (var queue in _responses.Values)
{
if (!queue.IsEmpty)
{
throw new InvalidOperationException("Not all canned responses were consumed.");
}
}
}
/// <summary>
/// Creates an <see cref="HttpClient"/> wired to this handler.
/// </summary>
public HttpClient CreateClient()
=> new(this, disposeHandler: false)
{
Timeout = TimeSpan.FromSeconds(10),
};
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is null)
{
throw new InvalidOperationException("Request URI is required for canned responses.");
}
var key = new RequestKey(request.Method ?? HttpMethod.Get, request.RequestUri);
var factory = DequeueFactory(key);
if (factory is null)
{
if (_fallback is null)
{
throw new InvalidOperationException($"No canned response registered for {request.Method} {request.RequestUri}.");
}
factory = _fallback;
}
var snapshot = CaptureRequest(request);
_requests.Enqueue(snapshot);
var response = factory(request);
response.RequestMessage ??= request;
return Task.FromResult(response);
}
private Func<HttpRequestMessage, HttpResponseMessage>? DequeueFactory(RequestKey key)
{
if (_responses.TryGetValue(key, out var queue) && queue.TryDequeue(out var factory))
{
return factory;
}
return null;
}
private static CannedRequestRecord CaptureRequest(HttpRequestMessage request)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in request.Headers)
{
headers[header.Key] = string.Join(',', header.Value);
}
if (request.Content is not null)
{
foreach (var header in request.Content.Headers)
{
headers[header.Key] = string.Join(',', header.Value);
}
}
return new CannedRequestRecord(
Timestamp: DateTimeOffset.UtcNow,
Method: request.Method ?? HttpMethod.Get,
Uri: request.RequestUri!,
Headers: headers);
}
private readonly record struct RequestKey(HttpMethod Method, string Uri)
{
public RequestKey(HttpMethod method, Uri uri)
: this(method, uri.ToString())
{
}
public bool Equals(RequestKey other)
=> string.Equals(Method.Method, other.Method.Method, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Uri, other.Uri, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode()
{
var methodHash = StringComparer.OrdinalIgnoreCase.GetHashCode(Method.Method);
var uriHash = StringComparer.OrdinalIgnoreCase.GetHashCode(Uri);
return HashCode.Combine(methodHash, uriHash);
}
}
private sealed class RequestKeyComparer : IEqualityComparer<RequestKey>
{
public static readonly RequestKeyComparer Instance = new();
public bool Equals(RequestKey x, RequestKey y) => x.Equals(y);
public int GetHashCode(RequestKey obj) => obj.GetHashCode();
}
public readonly record struct CannedRequestRecord(DateTimeOffset Timestamp, HttpMethod Method, Uri Uri, IReadOnlyDictionary<string, string> Headers);
private static HttpResponseMessage BuildTextResponse(HttpStatusCode statusCode, string content, string contentType)
{
var message = new HttpResponseMessage(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
return message;
}
public void AddJsonResponse(Uri requestUri, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
=> AddResponse(requestUri, () => BuildTextResponse(statusCode, json, "application/json"));
public void AddTextResponse(Uri requestUri, string content, string contentType = "text/plain", HttpStatusCode statusCode = HttpStatusCode.OK)
=> AddResponse(requestUri, () => BuildTextResponse(statusCode, content, contentType));
}