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);
}
}