summaryrefslogtreecommitdiffstats
path: root/tests/Cepis.Tests/FakeServer.cs
blob: 1bd4d603fc6ab5e7e34d07ca929c0039f4fa3952 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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);
    }
}