jevstrudel.git / worker / src / votes.test.ts
1import { readFileSync } from 'node:fs';
2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3import config from '../wrangler.json';
4import { testD1 } from '../test/d1';
5import { forgetSongs, SONGS_PATH, votes, whyNotVote, type Count, type Vote } from './votes';
6import { d1Votes } from './votes-store';
7
8const known = new Set(['jev/dial-up', 'jev/system-one', 'jev/hey-listen']);
9const vote = { a: 'jev/dial-up', b: 'jev/system-one', pick: 'jev/system-one' };
10
11describe('whyNotVote: only a pick between two of the site’s songs', () => {
12  it('accepts a vote either way round', () => {
13    expect(whyNotVote(vote, known)).toBeNull();
14    expect(whyNotVote({ ...vote, pick: vote.a }, known)).toBeNull();
15    expect(whyNotVote({ a: vote.b, b: vote.a, pick: vote.a }, known)).toBeNull();
16  });
17
18  it('refuses anything else', () => {
19    expect(whyNotVote(null, known)).toMatch(/object/);
20    expect(whyNotVote([vote], known)).toMatch(/object/);
21    expect(whyNotVote({ ...vote, visitor: 'x' }, known)).toMatch(/unexpected/);
22    expect(whyNotVote({ ...vote, pick: 1 }, known)).toMatch(/song ids/);
23    expect(whyNotVote({ a: vote.a, b: vote.a, pick: vote.a }, known)).toMatch(/two songs/);
24    expect(whyNotVote({ ...vote, b: 'jev/not-a-song', pick: vote.a }, known)).toMatch(/not a song/);
25    expect(whyNotVote({ ...vote, a: '../../etc', pick: vote.b }, new Set([...known, '../../etc']))).toMatch(
26      /not a song/,
27    );
28    expect(whyNotVote({ ...vote, pick: 'jev/hey-listen' }, known)).toMatch(/a or b/);
29  });
30});
31
32describe('votes: POST /jev/votes and GET /jev/votes/summary', () => {
33  let added: Vote[];
34  let songList: () => Response;
35  const store = {
36    add: async (v: Vote) => void added.push(v),
37    counts: async (): Promise<Count[]> => [{ ...vote, n: 3 }],
38  };
39  const env = (success = true, extra: Record<string, unknown> = {}) => ({
40    VOTE_LIMIT: { limit: async () => ({ success }) },
41    ASSETS: { fetch: async (r: Request) => (new URL(r.url).pathname === SONGS_PATH ? songList() : new Response('', { status: 404 })) },
42    ...extra,
43  });
44  const post = (body: unknown, method = 'POST') =>
45    new Request('https://jevstrudel.example/jev/votes', {
46      method,
47      body: method === 'POST' ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
48    });
49  const summary = () => new Request('https://jevstrudel.example/jev/votes/summary');
50
51  beforeEach(() => {
52    added = [];
53    songList = () => Response.json({ songs: [...known] });
54    forgetSongs();
55    vi.spyOn(console, 'error').mockImplementation(() => {});
56  });
57  afterEach(() => {
58    vi.restoreAllMocks();
59    vi.unstubAllGlobals();
60  });
61
62  it('counts a valid vote, and nothing else', async () => {
63    expect((await votes(post(vote), env() as never, store)).status).toBe(204);
64    expect(added).toEqual([vote]);
65    expect((await votes(post({ ...vote, b: 'jev/nope' }), env() as never, store)).status).toBe(400);
66    expect((await votes(post('not json'), env() as never, store)).status).toBe(400);
67    expect((await votes(post('x'.repeat(2000)), env() as never, store)).status).toBe(413);
68    expect((await votes(post(vote, 'GET'), env() as never, store)).status).toBe(405);
69    expect(added).toHaveLength(1);
70  });
71
72  it('is rate limited per visitor, like the relay', async () => {
73    const res = await votes(post(vote), env(false) as never, store);
74    expect(res.status).toBe(429);
75    expect(res.headers.get('Retry-After')).toBe(String(config.ratelimits.find((r) => r.name === 'VOTE_LIMIT')!.simple.period));
76    expect((await votes(summary(), env(false) as never, store)).status).toBe(429);
77    expect(added).toEqual([]);
78  });
79
80  it('refuses, uncounted, while the song list cannot be read', async () => {
81    songList = () => new Response('gone', { status: 404 });
82    expect((await votes(post(vote), env() as never, store)).status).toBe(503);
83    expect((await votes(post(vote), env(true, { ASSETS: undefined }) as never, store)).status).toBe(503);
84    expect(added).toEqual([]);
85  });
86
87  it("reads dev's song list from SONGS_URL before any ASSETS", async () => {
88    const fetch = vi.fn(async () => Response.json({ songs: ['jev/dial-up', 'jev/system-one'] }));
89    vi.stubGlobal('fetch', fetch);
90    songList = () => Response.json({ songs: [] });
91    const res = await votes(post(vote), env(true, { SONGS_URL: 'http://localhost:4322/jev/songs.json' }) as never, store);
92    expect(res.status).toBe(204);
93    expect(fetch).toHaveBeenCalledWith('http://localhost:4322/jev/songs.json');
94  });
95
96  it('answers the summary with the counts per (a, b, pick)', async () => {
97    const res = await votes(summary(), env() as never, store);
98    expect(res.status).toBe(200);
99    expect(await res.json()).toEqual({ votes: [{ ...vote, n: 3 }] });
100    expect((await votes(new Request(summary(), { method: 'POST', body: '{}' }), env() as never, store)).status).toBe(
101      405,
102    );
103  });
104});
105
106describe('the votes config', () => {
107  it("points dev's song list at the dev server", () => {
108    const url = new URL(config.env.dev.vars.SONGS_URL);
109    expect(url.pathname).toBe(SONGS_PATH);
110    const astro = readFileSync(new URL('../../website/astro.config.mjs', import.meta.url), 'utf8');
111    expect(astro).toContain(`port: ${url.port},`);
112  });
113
114  it('gives production and dev the same database, budgets, parties, tab hubs and lobby, and only dev the hub', () => {
115    expect(config.d1_databases).toEqual([{ binding: 'DB', database_name: 'jevstrudel', migrations_dir: 'migrations' }]);
116    expect(config.env.dev.d1_databases).toEqual(config.d1_databases);
117    const budget = { name: 'BUDGET', class_name: 'JevBudget' };
118    const party = { name: 'PARTY', class_name: 'Party' };
119    // a signed-in user's own tabs, reached only with their OAuth token: public on purpose
120    const tabs = { name: 'TAB_HUB', class_name: 'TabHub' };
121    // the site's one lobby: who is listening to what (lobby.ts)
122    const lobby = { name: 'LOBBY', class_name: 'LobbyRoom' };
123    // which parties are live, for the data tab (party-directory.ts): no room's name
124    const parties = { name: 'PARTY_DIRECTORY', class_name: 'PartyDirectory' };
125    // the dev hub drives any tab remotely, unauthenticated: never in production
126    expect(config.durable_objects.bindings).toEqual([budget, party, tabs, lobby, parties]);
127    expect(config.env.dev.durable_objects.bindings).toEqual([{ name: 'HUB', class_name: 'Hub' }, budget, party, tabs, lobby, parties]);
128    // v1 is deployed. Votes once held v2 as a Durable Object before D1 and
129    // never deployed, so v2 is the budgets'; v3 the party rooms'; v4 the tab hubs'; v5 the lobby's;
130    // v6 the party directory's.
131    expect(config.migrations).toEqual([
132      { tag: 'v1', new_sqlite_classes: ['Hub'] },
133      { tag: 'v2', new_sqlite_classes: ['JevBudget'] },
134      { tag: 'v3', new_sqlite_classes: ['Party'] },
135      { tag: 'v4', new_sqlite_classes: ['TabHub'] },
136      { tag: 'v5', new_sqlite_classes: ['LobbyRoom'] },
137      { tag: 'v6', new_sqlite_classes: ['PartyDirectory'] },
138    ]);
139    expect(config.kv_namespaces).toEqual([{ binding: 'CACHE' }, { binding: 'OAUTH_KV' }]);
140    expect(config.env.dev.kv_namespaces).toEqual(config.kv_namespaces);
141    expect(config.env.dev.ratelimits).toEqual(config.ratelimits);
142  });
143});
144
145describe('d1Votes: the counts in D1, against the real migrations', () => {
146  it('counts each (a, b, pick) apart, from one', async () => {
147    const store = d1Votes(testD1());
148    expect(await store.counts()).toEqual([]);
149    await store.add(vote);
150    await store.add(vote);
151    await store.add({ ...vote, pick: vote.a });
152    await store.add({ a: vote.b, b: vote.a, pick: vote.a });
153    expect(await store.counts()).toEqual([
154      { ...vote, pick: vote.a, n: 1 },
155      { ...vote, n: 2 },
156      { a: vote.b, b: vote.a, pick: vote.a, n: 1 },
157    ]);
158  });
159
160  it('refuses a row the schema cannot hold, whatever validated it', async () => {
161    const store = d1Votes(testD1());
162    await expect(store.add({ a: vote.a, b: vote.a, pick: vote.a })).rejects.toThrow(/CHECK/);
163    await expect(store.add({ ...vote, pick: 'jev/hey-listen' })).rejects.toThrow(/CHECK/);
164    expect(await store.counts()).toEqual([]);
165  });
166});