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