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