using System.Net; using System.Net.Sockets; using System.Text; namespace Cepis.Tests; /// One canned response. public sealed record Reply(int Status, string Body, IReadOnlyDictionary? Headers = null) { /// A reply with no extra headers. public static Reply Of(int status, string body) => new(status, body); /// A reply carrying one extra header. public static Reply Of(int status, string body, string header, string value) => new(status, body, new Dictionary(StringComparer.Ordinal) { [header] = value }); } /// One request the server saw. public sealed record Call(string Path, string? Ceps); /// /// A throwaway HTTP server on a free loopback port, with programmable routes and /// a request log. /// /// /// The tests point a real 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 BaseUrl works: if /// it did not, none of the suite would run. /// public sealed class FakeServer : IDisposable { private readonly HttpListener _listener = new(); private readonly Dictionary> _routes = new(StringComparer.Ordinal); private readonly List _calls = []; private readonly object _gate = new(); private Func? _handler; /// Start the server on a free port. 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); } /// Where the server listens, without a trailing slash. public string Url { get; } /// Every request seen so far, in order. public IReadOnlyList Calls { get { lock (_gate) { return [.. _calls]; } } } /// /// 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. /// public void Respond(string path, params Reply[] replies) { lock (_gate) { _routes[path] = new Queue(replies); } } /// Answer every request with this function instead of the routes. public void Handle(Func handler) { lock (_gate) { _handler = handler; } } /// How many requests so far went to that path. public int Count(string path) { lock (_gate) { return _calls.Count(call => call.Path == path); } } /// Stop the server. 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 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); } }