1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2import { testD1 } from '../test/d1'; 3import { d1Accounts } from './accounts-store'; 4import { d1Radio, KEPT, radio, RADIO_PATH, RECENT } from './radio'; 5import { newSessionToken, SESSION_TTL_MS } from './session'; 6import { forgetSongs, SONGS_PATH } from './votes'; 7 8const songs = ['jev/dial-up', 'jev/system-one', 'jev/hey-listen']; 9const USER = 'u'.repeat(22); 10 11describe('the radio history: GET and POST /jev/me/radio', () => { 12 let db: D1Database; 13 let token: string; 14 let clock: number; 15 const env = () => 16 ({ 17 DB: db, 18 AUTH_LIMIT: { limit: async () => ({ success: true }) }, 19 ASSETS: { 20 fetch: async (r: Request) => 21 new URL(r.url).pathname === SONGS_PATH ? Response.json({ songs }) : new Response('', { status: 404 }), 22 }, 23 }) as never; 24 const request = (method: string, body?: unknown, cookie = true, origin = 'https://jevstrudel.example') => 25 new Request(`https://jevstrudel.example${RADIO_PATH}`, { 26 method, 27 headers: { ...(cookie ? { Cookie: `jev_session=${token}` } : {}), ...(method === 'POST' ? { Origin: origin } : {}) }, 28 body: body === undefined ? undefined : JSON.stringify(body), 29 }); 30 const call = (method: string, body?: unknown, cookie = true, origin?: string) => 31 radio(request(method, body, cookie, origin), env(), d1Accounts(db), d1Radio(db, () => clock)); 32 33 beforeEach(async () => { 34 db = testD1(); 35 clock = Date.UTC(2026, 8, 25, 12); 36 forgetSongs(); 37 token = newSessionToken(); 38 await d1Accounts(db).createUser( 39 { id: USER, displayName: 'Link' }, 40 { id: 'c'.repeat(16), publicKey: new Uint8Array([1]), signCount: 0, transports: [] }, 41 token, 42 SESSION_TTL_MS, 43 ); 44 }); 45 afterEach(() => vi.restoreAllMocks()); 46 47 it('records what the radio played, and gives it back newest first', async () => { 48 for (const song of songs) { 49 clock += 1000; 50 expect((await call('POST', { song })).status).toBe(204); 51 } 52 const res = await call('GET'); 53 expect(res.status).toBe(200); 54 expect(await res.json()).toEqual({ 55 plays: [...songs].reverse().map((song, i) => ({ song, at: Date.UTC(2026, 8, 25, 12) + (3 - i) * 1000 })), 56 }); 57 }); 58 59 it('is only for a signed-in listener', async () => { 60 expect((await call('GET', undefined, false)).status).toBe(401); 61 expect((await call('POST', { song: songs[0] }, false)).status).toBe(401); 62 }); 63 64 it("takes only the site's own songs, from this site", async () => { 65 expect((await call('POST', { song: 'jev/not-a-song' })).status).toBe(400); 66 expect((await call('POST', { song: 7 })).status).toBe(400); 67 expect((await call('POST', 'x'.repeat(300))).status).toBe(413); 68 expect((await call('POST', { song: songs[0] }, true, 'https://evil.example')).status).toBe(403); 69 expect((await call('DELETE')).status).toBe(405); 70 expect(await (await call('GET')).json()).toEqual({ plays: [] }); 71 }); 72 73 it(`keeps the newest ${KEPT} per listener, and returns the newest ${RECENT}`, async () => { 74 const store = d1Radio(db, () => clock); 75 for (let i = 0; i < KEPT + 5; i++) { 76 clock += 1; 77 await store.add(USER, songs[i % 3]); 78 } 79 const { n } = (await db.prepare('SELECT count(*) AS n FROM radio_plays').first<{ n: number }>())!; 80 expect(n).toBe(KEPT); 81 const recent = await store.recent(USER); 82 expect(recent).toHaveLength(RECENT); 83 expect(recent[0].at).toBe(clock); 84 }); 85 86 it('refuses a row the schema cannot hold', async () => { 87 const store = d1Radio(db, () => clock); 88 await expect(store.add(USER, '../etc')).rejects.toThrow(/CHECK/); 89 await expect(store.add('v'.repeat(22), songs[0])).rejects.toThrow(/FOREIGN KEY/); 90 }); 91});