Restructure solution layout by module
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user