summaryrefslogtreecommitdiffstats
path: root/tests/Cepis.Tests/FakeServer.cs
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 13:10:34 -0300
commita2b10fd494fb389e556e40134031eb86e56e11e7 (patch)
tree08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /tests/Cepis.Tests/FakeServer.cs
downloadcsharp-a2b10fd494fb389e556e40134031eb86e56e11e7.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests/Cepis.Tests/FakeServer.cs')
-rw-r--r--tests/Cepis.Tests/FakeServer.cs168
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);
+ }
+}