jevstrudel.git / worker / src / listening.test.ts
1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2import config from '../wrangler.json';
3import { testD1 } from '../test/d1';
4import {
5  clean,
6  listening,
7  MAX_PERFORMANCE_BYTES,
8  newId,
9  PERFORMANCE_ID,
10  whyNotPerformance,
11  whyNotReaction,
12  type Listeners,
13  type Performance,
14} from './listening';
15import { d1Listening, segmentRows } from './listening-store';
16import { forgetSongs, SONGS_PATH } from './votes';
17import { d1Listeners } from './listening';
18import { d1Accounts } from './accounts-store';
19import { SESSION_COOKIE } from './session';
20
21const known = new Set(['jev/dial-up', 'jev/system-one']);
22const noon = (day: string) => Date.parse(`${day}T12:00:00Z`);
23const PLAYER = 'U'.repeat(22);
24const LISTENER = 'L'.repeat(22);

an account, as auth.ts makes one

26const withPlayer = (db = testD1()) => {
27  db.prepare('INSERT INTO users (id, display_name, created_at) VALUES (?, ?, ?)').bind(PLAYER, 'Link', 1).run();
28  return db;
29};
30const hash = 'a'.repeat(64);

Two jevs, as songs have them: the form's, and the parts asked after it.

33const performance = (): Performance => ({
34  song: 'jev/dial-up',
35  codeHash: hash,
36  model: 'jev-1.13.0',
37  every: 8,
38  ended: 'finished',
39  form: { jev: 0, question: 'section' },
40  jevs: [
41    {
42      segments: [
43        { status: 'answered', ms: 900, values: { section: { choice: 'intro', fallback: true, forced: true } } },
44        {
45          status: 'answered',
46          ms: 812,
47          values: {
48            section: {
49              choice: 'drop',
50              confidence: 0.7,
51              probabilities: { drop: 0.3, verse: 0.7 },
52              sampled: 0.7,
53              top: 'verse',
54            },
55          },
56        },
57        { status: 'fallback', values: { section: { choice: 'outro', fallback: true } } },
58      ],
59    },
60    {
61      segments: [
62        { status: 'answered', ms: 700, values: { drums: { score: 1.5, confidence: 0.6, probabilities: { '0': 0.1, '1': 0.4, '2': 0.5 } } } },
63        { status: 'answered', ms: 650, from: 10, values: { drums: { score: 2 }, drop: { noul: 0.9 } } },
64        { status: 'fallback', values: { drums: { score: 2, fallback: true, absent: true } } },
65      ],
66    },
67  ],
68});
70describe('whyNotReaction', () => {
71  it('accepts a 🔥 or 😴 on a section of a known song', () => {
72    expect(whyNotReaction({ song: 'jev/dial-up', section: 'drop2d', reaction: 'fire' }, known)).toBeNull();
73    expect(whyNotReaction({ song: 'jev/dial-up', section: '3', reaction: 'sleep' }, known)).toBeNull();
74  });
75  it('refuses anything else', () => {
76    const ok = { song: 'jev/dial-up', section: 'drop', reaction: 'fire' };
77    expect(whyNotReaction(null, known)).toMatch(/object/);
78    expect(whyNotReaction({ ...ok, visitor: 1 }, known)).toMatch(/unexpected/);
79    expect(whyNotReaction({ ...ok, song: 'jev/nope' }, known)).toMatch(/song/);
80    expect(whyNotReaction({ ...ok, section: '' }, known)).toMatch(/section/);
81    expect(whyNotReaction({ ...ok, section: '<script>' }, known)).toMatch(/section/);
82    expect(whyNotReaction({ ...ok, section: 'x'.repeat(65) }, known)).toMatch(/section/);
83    expect(whyNotReaction({ ...ok, reaction: 'love' }, known)).toMatch(/fire or sleep/);
84  });
85});
86
87describe('whyNotPerformance: size and shape', () => {
88  it('accepts a performance as the page records it', () => {
89    expect(whyNotPerformance(performance(), known)).toBeNull();
90    expect(whyNotPerformance({ ...performance(), form: null }, known)).toBeNull();
91  });
92
93  it('refuses a malformed one', () => {
94    const p = performance();
95    const bad = (change: (p: any) => void) => {
96      const copy = structuredClone(p) as any;
97      change(copy);
98      return whyNotPerformance(copy, known);
99    };
100    expect(bad((p) => (p.song = 'jev/nope'))).toMatch(/song/);
101    expect(bad((p) => (p.codeHash = 'abc'))).toMatch(/SHA-256/);
102    expect(bad((p) => (p.every = 0))).toMatch(/every/);
103    expect(bad((p) => (p.every = 1.5))).toMatch(/every/);
104    expect(bad((p) => (p.ended = 'paused'))).toMatch(/ended/);
105    expect(bad((p) => (p.visitor = 'x'))).toMatch(/unexpected/);
106    expect(bad((p) => (p.jevs = []))).toMatch(/jevs/);
107    expect(bad((p) => (p.jevs = Array(5).fill(p.jevs[0])))).toMatch(/jevs/);
108    expect(bad((p) => (p.jevs[0].segments = Array(65).fill(p.jevs[0].segments[0])))).toMatch(/segments/);
109    expect(bad((p) => (p.jevs[0].segments[1].status = 'asking'))).toMatch(/status/);
110    expect(bad((p) => (p.jevs[0].segments[1].ms = -1))).toMatch(/ms/);
111    expect(bad((p) => (p.jevs[1].segments[1].from = 2.5))).toMatch(/from/);
112    expect(bad((p) => (p.jevs[1].segments[1].extra = 1))).toMatch(/unexpected segment/);
113    expect(bad((p) => (p.jevs[1].segments[1].values = {}))).toMatch(/answers/);
114    expect(bad((p) => (p.jevs[1].segments[1].values['to string'] = { noul: 1 }))).toMatch(/question name/);
115    expect(bad((p) => (p.jevs[1].segments[1].values.drums = { score: 1, noul: 1 }))).toMatch(/exactly one/);
116    expect(bad((p) => (p.jevs[1].segments[1].values.drums = { score: 11 }))).toMatch(/score/);
117    expect(bad((p) => (p.jevs[1].segments[1].values.drop = { noul: 2 }))).toMatch(/noul/);
118    expect(bad((p) => (p.jevs[0].segments[1].values.section.choice = 'a b'))).toMatch(/option/);
119    expect(bad((p) => (p.jevs[0].segments[1].values.section.probabilities = { drop: 3 }))).toMatch(/probabilities/);
120    expect(bad((p) => (p.jevs[0].segments[1].values.section.fallback = false))).toMatch(/fallback/);
121    expect(bad((p) => (p.jevs[0].segments[1].values.section.note = 'hi'))).toMatch(/unexpected answer/);
122    expect(bad((p) => (p.form = { jev: 2, question: 'section' }))).toMatch(/form.jev/);
123    expect(bad((p) => (p.form = { jev: 1, question: 'drums' }))).toMatch(/chooses drums/);
124  });
125});
126
127describe('ids', () => {
128  it('are 22 random base64url characters', () => {
129    const ids = new Set(Array.from({ length: 200 }, newId));
130    expect(ids.size).toBe(200);
131    for (const id of ids) expect(id).toMatch(PERFORMANCE_ID);
132  });
133});
134
135describe('d1Listening: against the real migrations', () => {
136  it('counts reactions per (song, section, reaction) and tallies a song', async () => {
137    const store = d1Listening(testD1());
138    await store.react({ song: 'jev/dial-up', section: 'drop', reaction: 'fire' });
139    await store.react({ song: 'jev/dial-up', section: 'drop', reaction: 'fire' });
140    await store.react({ song: 'jev/dial-up', section: 'drop', reaction: 'sleep' });
141    await store.react({ song: 'jev/dial-up', section: 'intro', reaction: 'sleep' });
142    await store.react({ song: 'jev/system-one', section: 'drop', reaction: 'fire' });
143    expect(await store.tally('jev/dial-up')).toEqual([
144      { section: 'drop', fire: 2, sleep: 1 },
145      { section: 'intro', fire: 0, sleep: 1 },
146    ]);
147    expect(await store.tally('jev/nope')).toEqual([]);
148    await expect(store.react({ song: 'jev/dial-up', section: 'drop', reaction: 'meh' as never })).rejects.toThrow(/CHECK/);
149  });
150
151  it('stores a performance as a row per segment per jev, and gives it back whole', async () => {
152    const db = testD1();
153    const store = d1Listening(db);
154    const p = clean(performance());
155    await store.record('A'.repeat(22), '2026-09-25', p);
156    // stored without a player or a time: signed out, dated noon of its day
157    expect(await store.performance('A'.repeat(22))).toEqual({
158      ...p,
159      id: 'A'.repeat(22),
160      day: '2026-09-25',
161      at: noon('2026-09-25'),
162      player: null,
163    });
164    expect(await store.performance('B'.repeat(22))).toBeNull();
165
166    const { results } = await db
167      .prepare('SELECT jev, segment, section, after_section, status, ms, from_cycle FROM performance_segments ORDER BY jev, segment')
168      .all();
169    expect(results).toEqual([
170      { jev: 0, segment: 0, section: 'intro', after_section: null, status: 'answered', ms: 900, from_cycle: null },
171      { jev: 0, segment: 1, section: 'drop', after_section: 'intro', status: 'answered', ms: 812, from_cycle: null },
172      { jev: 0, segment: 2, section: 'outro', after_section: 'drop', status: 'fallback', ms: null, from_cycle: null },
173      { jev: 1, segment: 0, section: 'intro', after_section: null, status: 'answered', ms: 700, from_cycle: null },
174      { jev: 1, segment: 1, section: 'drop', after_section: 'intro', status: 'answered', ms: 650, from_cycle: 10 },
175      { jev: 1, segment: 2, section: 'outro', after_section: 'drop', status: 'fallback', ms: null, from_cycle: null },
176    ]);
177    // the history is one query: what Jev picked after the intro
178    const picks = await db
179      .prepare(
180        `SELECT json_extract(answers, '$.section.choice') AS pick, count(*) AS n FROM performance_segments
181         WHERE jev = 0 AND after_section = 'intro' GROUP BY pick`,
182      )
183      .all();
184    expect(picks.results).toEqual([{ pick: 'drop', n: 1 }]);
185  });
186
187  it('writes a performance all or nothing', async () => {
188    const db = testD1();
189    const store = d1Listening(db);
190    const p = clean(performance());
191    await store.record('A'.repeat(22), '2026-09-25', p);
192    // the same id again: the performance row fails, and no segment is added
193    await expect(store.record('A'.repeat(22), '2026-09-26', p)).rejects.toThrow();
194    expect(await db.prepare('SELECT count(*) AS n FROM performance_segments').first('n')).toBe(6);
195    await expect(store.record('short', '2026-09-25', p)).rejects.toThrow(/CHECK/);
196    expect(await db.prepare('SELECT count(*) AS n FROM performances').first('n')).toBe(1);
197  });
198
199  it('names who played a take and when; the take outlives their account, anonymous', async () => {
200    const db = await withPlayer();
201    const store = d1Listening(db);
202    const at = Date.parse('2026-09-27T14:05:31Z');
203    await store.record('A'.repeat(22), '2026-09-27', clean(performance()), { at, player: PLAYER });
204    expect(await store.performance('A'.repeat(22))).toMatchObject({ at, player: { id: PLAYER, name: 'Link' } });
205    await db.prepare('DELETE FROM users WHERE id = ?').bind(PLAYER).run();
206    expect(await store.performance('A'.repeat(22))).toMatchObject({ at, player: null });
207    // a player that is no account cannot be written
208    await expect(store.record('B'.repeat(22), '2026-09-27', clean(performance()), { at, player: 'N'.repeat(22) })).rejects.toThrow(
209      /FOREIGN KEY/,
210    );
211  });
212
213  it("lists an account's takes, newest first, a listener song's with its public title", async () => {
214    const db = await withPlayer();
215    const store = d1Listening(db);
216    await db
217      .prepare('INSERT INTO listener_songs (id, user_id, created_at) VALUES (?, ?, ?)')
218      .bind(LISTENER, PLAYER, 1)
219      .run();
220    const revise = (id: string, rev: number, title: string) =>
221      db
222        .prepare(
223          `INSERT INTO song_revisions (id, song, rev, title, description, spec, code, created_at, screen)
224           VALUES (?, ?, ?, ?, '', '', 's("bd")', 1, 'pending')`,
225        )
226        .bind(id, LISTENER, rev, title)
227        .run();
228    await revise('R'.repeat(22), 1, 'Boomerang');
229    await db.prepare(`UPDATE song_revisions SET screen = 'fine', screened_at = 2 WHERE id = ?`).bind('R'.repeat(22)).run();
230    // a newer revision still waiting on Jev: its title is not public yet
231    await revise('Q'.repeat(22), 2, 'Not screened yet');
232    await store.record('a'.repeat(22), '2026-09-27', clean(performance()), { at: 1000, player: PLAYER });
233    await store.record('b'.repeat(22), '2026-09-27', clean({ ...performance(), song: `listener:${LISTENER}` }), {
234      at: 3000,
235      player: PLAYER,
236    });
237    await store.record('c'.repeat(22), '2026-09-27', clean(performance()), { at: 2000, player: null });
238    const takes = await store.playerTakes(PLAYER, 10);
239    expect(takes.map((t) => [t.id[0], t.song, t.title, t.at])).toEqual([
240      ['b', `listener:${LISTENER}`, 'Boomerang', 3000],
241      ['a', 'jev/dial-up', null, 1000],
242    ]);
243    expect(takes[1]).toMatchObject({ player: { id: PLAYER, name: 'Link' }, path: ['intro', 'drop', 'outro'], segments: 3 });
244    expect(await store.playerTakes(PLAYER, 1)).toHaveLength(1);
245    expect(await store.playerTakes('N'.repeat(22), 10)).toEqual([]);
246  });
247
248  it('segmentRows: no form, no sections', () => {
249    const rows = segmentRows({ ...clean(performance()), form: null });
250    expect(rows.every((r) => r.section === null && r.after_section === null)).toBe(true);
251  });
252});
253
254describe('listening: the routes', () => {
255  let songList: () => Response;
256  const env = (listen = true, record = true) => ({
257    LISTEN_LIMIT: { limit: async () => ({ success: listen }) },
258    RECORD_LIMIT: { limit: async () => ({ success: record }) },
259    ASSETS: { fetch: async (r: Request) => (new URL(r.url).pathname === SONGS_PATH ? songList() : new Response('', { status: 404 })) },
260  });
261  const req = (path: string, method = 'GET', body?: unknown, headers: Record<string, string> = {}) =>
262    new Request(`https://jevstrudel.example${path}`, {
263      method,
264      headers,
265      body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
266    });
267  // who is signed in, and which listener songs are public, as d1Listeners answers
268  const listeners = (player: string | null = null, publicSongs: string[] = []): Listeners => ({
269    player: async () => player,
270    isPublicSong: async (id) => publicSongs.includes(id),
271  });
272
273  beforeEach(() => {
274    songList = () => Response.json({ songs: [...known] });
275    forgetSongs();
276    vi.spyOn(console, 'error').mockImplementation(() => {});
277  });
278  afterEach(() => vi.restoreAllMocks());
279
280  it('counts a reaction and tallies it', async () => {
281    const store = d1Listening(testD1());
282    const r = { song: 'jev/dial-up', section: 'drop', reaction: 'fire' };
283    expect((await listening(req('/jev/reactions', 'POST', r), env() as never, store)).status).toBe(204);
284    expect((await listening(req('/jev/reactions', 'POST', { ...r, song: 'jev/x' }), env() as never, store)).status).toBe(400);
285    expect((await listening(req('/jev/reactions', 'POST', 'x'.repeat(600)), env() as never, store)).status).toBe(413);
286    const res = await listening(req('/jev/reactions?song=jev/dial-up'), env() as never, store);
287    expect(await res.json()).toEqual({ song: 'jev/dial-up', sections: [{ section: 'drop', fire: 1, sleep: 0 }] });
288    expect((await listening(req('/jev/reactions?song=../x'), env() as never, store)).status).toBe(400);
289    expect((await listening(req('/jev/reactions', 'PUT', r), env() as never, store)).status).toBe(405);
290  });
291
292  it('stores a performance under a new id, and replays it by that id', async () => {
293    const store = d1Listening(testD1());
294    const res = await listening(req('/jev/performances', 'POST', performance()), env() as never, store);
295    expect(res.status).toBe(201);
296    const { id } = (await res.json()) as { id: string };
297    expect(id).toMatch(PERFORMANCE_ID);
298    const back = await listening(req(`/jev/performances/${id}`), env() as never, store);
299    expect(back.status).toBe(200);
300    expect(back.headers.get('Cache-Control')).toMatch(/immutable/);
301    expect(await back.json()).toMatchObject({ id, ...performance() });
302    expect((await listening(req('/jev/performances/nope'), env() as never, store)).status).toBe(404);
303    expect((await listening(req(`/jev/performances/${'Z'.repeat(22)}`), env() as never, store)).status).toBe(404);
304    expect((await listening(req('/jev/performances'), env() as never, store)).status).toBe(400);
305    expect((await listening(req('/jev/performances', 'PUT', {}), env() as never, store)).status).toBe(405);
306    expect((await listening(req(`/jev/performances/${id}`, 'POST', {}), env() as never, store)).status).toBe(405);
307  });
308
309  it("lists a song's takes, newest first, each with its path through the form", async () => {
310    const store = d1Listening(testD1());
311    const other = performance();
312    other.jevs[0].segments[1].values.section = { choice: 'verse' };
313    await store.record('a'.repeat(22), '2026-09-26', performance());
314    await store.record('b'.repeat(22), '2026-09-27', other);
315    await store.record('c'.repeat(22), '2026-09-27', { ...performance(), song: 'jev/system-one' });
316    const res = await listening(req('/jev/performances?song=jev/dial-up'), env() as never, store);
317    expect(res.status).toBe(200);
318    expect(res.headers.get('Cache-Control')).toBe('public, max-age=60');
319    expect(await res.json()).toEqual({
320      song: 'jev/dial-up',
321      total: 2,
322      takes: [
323        {
324          id: 'b'.repeat(22),
325          day: '2026-09-27',
326          at: noon('2026-09-27'),
327          player: null,
328          ended: 'finished',
329          codeHash: hash,
330          segments: 3,
331          fallbacks: 1,
332          path: ['intro', 'verse', 'outro'],
333        },
334        {
335          id: 'a'.repeat(22),
336          day: '2026-09-26',
337          at: noon('2026-09-26'),
338          player: null,
339          ended: 'finished',
340          codeHash: hash,
341          segments: 3,
342          fallbacks: 1,
343          path: ['intro', 'drop', 'outro'],
344        },
345      ],
346    });
347    const second = await listening(req('/jev/performances?song=jev/dial-up&limit=1&offset=1'), env() as never, store);
348    expect(((await second.json()) as { takes: { id: string }[] }).takes.map((t) => t.id)).toEqual(['a'.repeat(22)]);
349    for (const bad of ['song=../x', 'song=jev/dial-up&limit=0', 'song=jev/dial-up&limit=51', 'song=jev/dial-up&offset=-1']) {
350      expect((await listening(req(`/jev/performances?${bad}`), env() as never, store)).status, bad).toBe(400);
351    }
352  });
353
354  it('says how Jev moves through a song: next sections with shares, fallbacks and answer times', async () => {
355    const store = d1Listening(testD1());
356    const other = performance();
357    other.jevs[0].segments[1].values.section = { choice: 'verse' };
358    other.jevs[0].segments[0].ms = 100;
359    other.jevs[1].segments[0].ms = 50;
360    await store.record('a'.repeat(22), '2026-09-26', performance());
361    await store.record('b'.repeat(22), '2026-09-27', other);
362    const res = await listening(req('/jev/performances/moves?song=jev/dial-up'), env() as never, store);
363    expect(res.status).toBe(200);
364    expect(await res.json()).toEqual({
365      song: 'jev/dial-up',
366      performances: 2,
367      of: 2,
368      sections: [
369        { section: 'drop', plays: 1, asked: 1, fallbacks: 0, ms: { median: 812, p90: 812 }, next: [{ section: 'outro', n: 1, share: 1 }] },
370        {
371          section: 'intro',
372          plays: 2,
373          asked: 2,
374          fallbacks: 0,
375          // a segment takes as long as its slowest jev(): 900 and 100
376          ms: { median: 100, p90: 900 },
377          next: [
378            { section: 'drop', n: 1, share: 0.5 },
379            { section: 'verse', n: 1, share: 0.5 },
380          ],
381        },
382        { section: 'outro', plays: 2, asked: 2, fallbacks: 2, ms: null, next: [] },
383        { section: 'verse', plays: 1, asked: 1, fallbacks: 0, ms: { median: 812, p90: 812 }, next: [{ section: 'outro', n: 1, share: 1 }] },
384      ],
385    });
386    const none = await listening(req('/jev/performances/moves?song=jev/system-one'), env() as never, store);
387    expect(await none.json()).toEqual({ song: 'jev/system-one', performances: 0, of: 0, sections: [] });
388    expect((await listening(req('/jev/performances/moves'), env() as never, store)).status).toBe(400);
389  });
390
391  it("numbers a song's sections when it has no form, and gives no path", async () => {
392    const store = d1Listening(testD1());
393    await store.record('a'.repeat(22), '2026-09-26', { ...performance(), form: null });
394    const takes = (await (await listening(req('/jev/performances?song=jev/dial-up'), env() as never, store)).json()) as {
395      takes: { path: unknown; segments: number }[];
396    };
397    expect(takes.takes[0]).toMatchObject({ path: null, segments: 3 });
398    const moves = (await (await listening(req('/jev/performances/moves?song=jev/dial-up'), env() as never, store)).json()) as {
399      sections: { section: string; next: unknown[] }[];
400    };
401    expect(moves.sections.map((s) => [s.section, s.next.length])).toEqual([
402      ['1', 0],
403      ['2', 0],
404      ['3', 0],
405    ]);
406  });
407
408  it('records who played it from the session, never from the body, and when', async () => {
409    const store = d1Listening(await withPlayer());
410    const before = Date.now();
411    // the body has no field for who or when: one that tries is refused
412    for (const extra of [{ player: 'N'.repeat(22) }, { user_id: 'N'.repeat(22) }, { at: 1 }]) {
413      const res = await listening(req('/jev/performances', 'POST', { ...performance(), ...extra }), env() as never, store, listeners(PLAYER));
414      expect(res.status, JSON.stringify(extra)).toBe(400);
415    }
416    const res = await listening(req('/jev/performances', 'POST', performance()), env() as never, store, listeners(PLAYER));
417    const { id } = (await res.json()) as { id: string };
418    const back = (await store.performance(id))!;
419    expect(back.player).toEqual({ id: PLAYER, name: 'Link' });
420    expect(back.at).toBeGreaterThanOrEqual(before);
421    expect(back.day).toBe(new Date(back.at).toISOString().slice(0, 10));
422    // signed out, no player
423    const anon = await listening(req('/jev/performances', 'POST', performance()), env() as never, store, listeners(null));
424    expect((await store.performance(((await anon.json()) as { id: string }).id))!.player).toBeNull();
425  });
426
427  it("takes the player from this site's session cookie (d1Listeners), and none off-site", async () => {
428    const db = await withPlayer();
429    await d1Accounts(db).createSession(PLAYER, 'TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT', 60_000);
430    const who = d1Listeners({ DB: db } as never);
431    const cookie = { Cookie: `${SESSION_COOKIE}=TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT` };
432    expect(await who.player(req('/jev/performances', 'POST', {}, cookie))).toBe(PLAYER);
433    expect(await who.player(req('/jev/performances', 'POST', {}))).toBeNull();
434    expect(await who.player(req('/jev/performances', 'POST', {}, { ...cookie, Origin: 'null' }))).toBeNull();
435    expect(await who.isPublicSong(LISTENER)).toBe(false);
436  });
437
438  it("records a listener's public song, and refuses one that is not public", async () => {
439    const store = d1Listening(testD1());
440    const song = `listener:${LISTENER}`;
441    const ok = await listening(req('/jev/performances', 'POST', { ...performance(), song }), env() as never, store, listeners(null, [LISTENER]));
442    expect(ok.status).toBe(201);
443    const { id } = (await ok.json()) as { id: string };
444    expect((await store.performance(id))!.song).toBe(song);
445    const takes = await listening(req(`/jev/performances?song=${encodeURIComponent(song)}`), env() as never, store);
446    expect(((await takes.json()) as { total: number }).total).toBe(1);
447    const moves = await listening(req(`/jev/performances/moves?song=${encodeURIComponent(song)}`), env() as never, store);
448    expect(((await moves.json()) as { performances: number }).performances).toBe(1);
449    // held, pending or no such song: refused, unstored
450    const held = await listening(req('/jev/performances', 'POST', { ...performance(), song }), env() as never, store, listeners());
451    expect(held.status).toBe(400);
452    for (const bad of ['listener:short', `listener:${LISTENER}x`, `listeners:${LISTENER}`]) {
453      const res = await listening(req('/jev/performances', 'POST', { ...performance(), song: bad }), env() as never, store, listeners(null, [LISTENER]));
454      expect(res.status, bad).toBe(400);
455    }
456  });
457
458  it('refuses a performance too large or malformed, unstored', async () => {
459    const db = testD1();
460    const store = d1Listening(db);
461    const big = JSON.stringify({ ...performance(), pad: 'x'.repeat(MAX_PERFORMANCE_BYTES) });
462    expect((await listening(req('/jev/performances', 'POST', big), env() as never, store)).status).toBe(413);
463    expect((await listening(req('/jev/performances', 'POST', { ...performance(), every: -1 }), env() as never, store)).status).toBe(400);
464    expect((await listening(req('/jev/performances', 'POST', 'not json'), env() as never, store)).status).toBe(400);
465    expect(await db.prepare('SELECT count(*) AS n FROM performances').first('n')).toBe(0);
466  });
467
468  it('rate limits reading and reacting apart from recording', async () => {
469    const store = d1Listening(testD1());
470    const res = await listening(req('/jev/performances', 'POST', performance()), env(true, false) as never, store);
471    expect(res.status).toBe(429);
472    expect(res.headers.get('Retry-After')).toBe('60');
473    expect((await listening(req('/jev/reactions?song=jev/dial-up'), env(true, false) as never, store)).status).toBe(200);
474    expect((await listening(req('/jev/reactions?song=jev/dial-up'), env(false, true) as never, store)).status).toBe(429);
475  });
476
477  it('refuses, unstored, while the song list cannot be read', async () => {
478    songList = () => new Response('gone', { status: 500 });
479    const store = d1Listening(testD1());
480    expect((await listening(req('/jev/performances', 'POST', performance()), env() as never, store)).status).toBe(503);
481  });
482});
483
484describe('the listening config', () => {
485  it('has both limits in production and dev, by name', () => {
486    for (const name of ['LISTEN_LIMIT', 'RECORD_LIMIT']) {
487      const prod = config.ratelimits.find((r) => r.name === name);
488      expect(prod?.simple.period).toBe(60);
489      expect(config.env.dev.ratelimits.find((r) => r.name === name)).toEqual(prod);
490    }
491    const ids = config.ratelimits.map((r) => r.namespace_id);
492    expect(new Set(ids).size, 'each limit its own namespace').toBe(ids.length);
493  });
494});