jevstrudel.git / worker / src / radio.test.ts
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});