diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 13:10:34 -0300 |
| commit | a2b10fd494fb389e556e40134031eb86e56e11e7 (patch) | |
| tree | 08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /tests/Cepis.Tests/FakeServer.cs | |
| download | csharp-a2b10fd494fb389e556e40134031eb86e56e11e7.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests/Cepis.Tests/FakeServer.cs')
| -rw-r--r-- | tests/Cepis.Tests/FakeServer.cs | 168 |
1 files changed, 168 insertions, 0 deletions
diff --git a/tests/Cepis.Tests/FakeServer.cs b/tests/Cepis.Tests/FakeServer.cs new file mode 100644 index 0000000..1bd4d60 --- /dev/null +++ b/tests/Cepis.Tests/FakeServer.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Cepis.Tests; + +/// <summary>One canned response.</summary> +public sealed record Reply(int Status, string Body, IReadOnlyDictionary<string, string>? Headers = null) +{ + /// <summary>A reply with no extra headers.</summary> + public static Reply Of(int status, string body) => new(status, body); + + /// <summary>A reply carrying one extra header.</summary> + public static Reply Of(int status, string body, string header, string value) => + new(status, body, new Dictionary<string, string>(StringComparer.Ordinal) { [header] = value }); +} + +/// <summary>One request the server saw.</summary> +public sealed record Call(string Path, string? Ceps); + +/// <summary> +/// A throwaway HTTP server on a free loopback port, with programmable routes and +/// a request log. +/// </summary> +/// <remarks> +/// The tests point a real <see cref="CepisClient"/> at it, so the code path they +/// exercise is the one that runs in production, request construction and error +/// body reading included. It doubles as the test that <c>BaseUrl</c> works: if +/// it did not, none of the suite would run. +/// </remarks> +public sealed class FakeServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Dictionary<string, Queue<Reply>> _routes = new(StringComparer.Ordinal); + private readonly List<Call> _calls = []; + private readonly object _gate = new(); + private Func<Call, Reply>? _handler; + + /// <summary>Start the server on a free port.</summary> + public FakeServer() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + Url = $"http://127.0.0.1:{port}"; + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + _ = Task.Run(LoopAsync); + } + + /// <summary>Where the server listens, without a trailing slash.</summary> + public string Url { get; } + + /// <summary>Every request seen so far, in order.</summary> + public IReadOnlyList<Call> Calls + { + get + { + lock (_gate) + { + return [.. _calls]; + } + } + } + + /// <summary> + /// Register the replies for a path. Each request consumes the next one; the + /// last repeats once the list runs out, which is how retrying is tested. + /// </summary> + public void Respond(string path, params Reply[] replies) + { + lock (_gate) + { + _routes[path] = new Queue<Reply>(replies); + } + } + + /// <summary>Answer every request with this function instead of the routes.</summary> + public void Handle(Func<Call, Reply> handler) + { + lock (_gate) + { + _handler = handler; + } + } + + /// <summary>How many requests so far went to that path.</summary> + public int Count(string path) + { + lock (_gate) + { + return _calls.Count(call => call.Path == path); + } + } + + /// <summary>Stop the server.</summary> + public void Dispose() => _listener.Close(); + + private async Task LoopAsync() + { + while (_listener.IsListening) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync().ConfigureAwait(false); + } + catch (Exception stopped) when (stopped is HttpListenerException + or ObjectDisposedException + or InvalidOperationException) + { + return; + } + + _ = Task.Run(() => Serve(context)); + } + } + + private void Serve(HttpListenerContext context) + { + var call = new Call(context.Request.Url!.AbsolutePath, context.Request.QueryString["ceps"]); + var reply = Next(call); + var body = Encoding.UTF8.GetBytes(reply.Body); + + try + { + context.Response.StatusCode = reply.Status; + if (reply.Headers is not null) + { + foreach (var (name, value) in reply.Headers) + { + context.Response.Headers[name] = value; + } + } + + context.Response.ContentLength64 = body.Length; + context.Response.OutputStream.Write(body); + context.Response.OutputStream.Close(); + } + catch (Exception gone) when (gone is HttpListenerException or ObjectDisposedException or IOException) + { + } + } + + private Reply Next(Call call) + { + Func<Call, Reply> handler; + lock (_gate) + { + _calls.Add(call); + if (_handler is null) + { + if (!_routes.TryGetValue(call.Path, out var queue) || queue.Count == 0) + { + return Reply.Of(404, Fixtures.NotFound); + } + + return queue.Count > 1 ? queue.Dequeue() : queue.Peek(); + } + + handler = _handler; + } + + return handler(call); + } +} |