jevstrudel.git / website / src / jev / ask.test.mjs
1import { afterEach, describe, expect, it, vi } from 'vitest';
2import { ATTEMPT_HEADER, askJev, BUDGET_HEADER, LEAD_HEADER, onBudget, resetBackoff } from './ask.mjs';
3
4const answer = (body, status = 200) => new Response(JSON.stringify(body), { status });
5
6describe('askJev', () => {
7  afterEach(() => {
8    vi.useRealTimers();
9    vi.unstubAllGlobals();
10  });
11
12  it('asks again after a dropped connection or a 5xx, and gives up after two retries', async () => {
13    vi.useFakeTimers();
14    const fetch = vi
15      .fn()
16      .mockRejectedValueOnce(new TypeError('Failed to fetch'))
17      .mockResolvedValueOnce(new Response('relay down', { status: 500 }))
18      .mockResolvedValueOnce(answer({ answers: { move: { choice: 'a' } } }));
19    vi.stubGlobal('fetch', fetch);
20    const asked = askJev({}, {});
21    await vi.runAllTimersAsync();
22    await expect(asked).resolves.toEqual({ move: { choice: 'a' } });
23    expect(fetch).toHaveBeenCalledTimes(3);
24
25    const down = vi.fn().mockResolvedValue(new Response('relay down', { status: 502 }));
26    vi.stubGlobal('fetch', down);
27    const failed = askJev({}, {}).catch((e) => e.message);
28    await vi.runAllTimersAsync();
29    expect(await failed).toMatch(/relay answered 502/);
30    expect(down).toHaveBeenCalledTimes(3);
31  });
32
33  it("tells the relay each attempt's number and how far ahead of its section it went out", async () => {
34    vi.useFakeTimers();
35    const fetch = vi
36      .fn()
37      .mockResolvedValueOnce(new Response('relay down', { status: 500 }))
38      .mockResolvedValueOnce(answer({ answers: {} }));
39    vi.stubGlobal('fetch', fetch);
40    const leads = [5.678, 4.5];
41    const asked = askJev({}, {}, { secondsToSection: () => leads.shift() });
42    await vi.runAllTimersAsync();
43    await asked;
44    const headers = fetch.mock.calls.map(([, init]) => init.headers);
45    expect(headers[0]).toMatchObject({ [LEAD_HEADER]: '5.68', [ATTEMPT_HEADER]: '0' });
46    expect(headers[1]).toMatchObject({ [LEAD_HEADER]: '4.50', [ATTEMPT_HEADER]: '1' });
47
48    // not playing (or no timing at all): no lead header
49    vi.useRealTimers();
50    fetch.mockImplementation(async () => answer({ answers: {} }));
51    await askJev({}, {}, { secondsToSection: () => null });
52    await askJev({}, {});
53    for (const [, init] of fetch.mock.calls.slice(2)) expect(init.headers).not.toHaveProperty(LEAD_HEADER);
54  });
55
56  it("reports a signed-in listener's budget, and says so when it is spent, until midnight or a sign-out", async () => {
57    const seen = [];
58    const stop = onBudget((v) => seen.push(v));
59    const fetch = vi
60      .fn()
61      .mockResolvedValueOnce(new Response(JSON.stringify({ answers: {} }), { headers: { [BUDGET_HEADER]: '7/10' } }))
62      .mockResolvedValueOnce(
63        new Response('your daily Jev budget (10 calls) is spent', {
64          status: 429,
65          headers: { [BUDGET_HEADER]: 'spent', 'Retry-After': '3600' },
66        }),
67      )
68      .mockResolvedValue(answer({ answers: {} }));
69    vi.stubGlobal('fetch', fetch);
70    await askJev({}, {});
71    await expect(askJev({}, {})).rejects.toThrow(/daily Jev budget is spent; it resets at 00:00 UTC/);
72    // backing off until then, without asking
73    await expect(askJev({}, {})).rejects.toThrow(/daily Jev budget is spent/);
74    expect(fetch).toHaveBeenCalledTimes(2);
75    expect(seen).toEqual(['7/10', 'spent']);
76    // signing out (or in as someone else) lifts it
77    resetBackoff();
78    await expect(askJev({}, {})).resolves.toEqual({});
79    stop();
80  });
81});