jevstrudel.git / worker / src / party-room.test.ts

The listening party's room (party-room.ts), with fake sockets and an in-memory stand-in for the Durable Object's storage.

3import { describe, expect, it } from 'vitest';
4import { admit, BURST, MAX_MESSAGE_BYTES, MAX_PEOPLE, party, Room, type Peer, type Storage } from './party-room';
6function memoryStorage(): Storage & { map: Map<string, unknown>; confirmed: number } {
7  const map = new Map<string, unknown>();
8  const store = {
9    map,
10    // writes that would hold the room's broadcasts until on disk
11    confirmed: 0,
12    async get<T>(key: string) {
13      return structuredClone(map.get(key)) as T | undefined;
14    },
15    async put(entries: Record<string, unknown>, options?: { allowUnconfirmed?: boolean }) {
16      if (!options?.allowUnconfirmed) store.confirmed++;
17      for (const [k, v] of Object.entries(entries)) map.set(k, structuredClone(v));
18    },
19    async delete(keys: string[], options?: { allowUnconfirmed?: boolean }) {
20      if (!options?.allowUnconfirmed) store.confirmed++;
21      let n = 0;
22      for (const k of keys) n += map.delete(k) ? 1 : 0;
23      return n;
24    },
25    async list<T>({ prefix }: { prefix: string }) {
26      const keys = [...map.keys()].filter((k) => k.startsWith(prefix)).sort();
27      return new Map(keys.map((k) => [k, structuredClone(map.get(k)) as T]));
28    },
29    async deleteAll(options?: { allowUnconfirmed?: boolean }) {
30      if (!options?.allowUnconfirmed) store.confirmed++;
31      map.clear();
32    },
33  };
34  return store;
35}
36
37type Fake = Peer & { got: any[]; closed?: [number | undefined, string | undefined] };
38const fake = (role: 'host' | 'guest'): Fake => {
39  const got: any[] = [];
40  return {
41    got,
42    send: (m: string) => void got.push(JSON.parse(m)),
43    close(code, reason) {
44      this.closed = [code, reason];
45    },
46    deserializeAttachment: () => ({ role, id: role }),
47  };
48};
49
50const HOST_KEY = 'host-key-0123456789abcdef';
51const OWN_ROOM = 'the-own-room-0123456789';
52
53function setup() {
54  const storage = memoryStorage();
55  const peers: Fake[] = [];
56  let now = 1_000_000;
57  // what the room tells the party directory (party-directory.ts)
58  const reports: unknown[] = [];
59  // this room's own name, as party.ts proves it (idFromName(name) is this object)
60  const room = new Room(storage, () => peers, () => now, (live) => reports.push(live), (name) => name === OWN_ROOM);
61  const join = async (role: 'host' | 'guest', key: string | null = role === 'host' ? HOST_KEY : null) => {
62    const refused = await admit(storage, peers, { role, key });
63    if (refused) return refused;
64    const p = fake(role);
65    peers.push(p);
66    await room.joined(p, role, key);
67    return p;
68  };
69  const leave = async (p: Fake) => {
70    await room.left(p);
71    peers.splice(peers.indexOf(p), 1);
72  };
73  const say = (p: Fake, m: unknown) => room.message(p, JSON.stringify(m));
74  return { storage, peers, room, join, leave, say, reports, tick: (ms: number) => (now += ms) };
75}
76
77const last = (p: Fake, t: string) => [...p.got].reverse().find((m) => m.t === t);
78
79describe('admit: who may join a room', () => {
80  it('lets a host start a party and refuses a guest where there is none', async () => {
81    const { join } = setup();
82    expect(await join('guest')).toEqual({ status: 404, why: expect.stringMatching(/no party/) });
83    const host = (await join('host')) as Fake;
84    expect(last(host, 'welcome')).toMatchObject({ role: 'host', people: 1, host: true, run: null });
85    expect(await join('guest')).toHaveProperty('got');
86  });
87
88  it('keeps the party its first host’s: another key is refused, the same key reconnects', async () => {
89    const { join } = setup();
90    const host = (await join('host')) as Fake;
91    await join('guest');
92    expect(await join('host', 'someone-else-0123456789')).toEqual({ status: 403, why: expect.any(String) });
93    expect(await join('host', null)).toEqual({ status: 400, why: expect.any(String) });
94    const again = (await join('host')) as Fake;
95    expect(last(again, 'welcome').role).toBe('host');
96    expect(host.closed?.[0]).toBe(4000); // the old connection is replaced
97    expect(await join('bystander' as never)).toEqual({ status: 400, why: expect.any(String) });
98  });
99
100  it(`holds at most ${MAX_PEOPLE}`, async () => {
101    const { join } = setup();
102    await join('host');
103    for (let i = 1; i < MAX_PEOPLE; i++) expect(await join('guest')).toHaveProperty('got');
104    expect(await join('guest')).toEqual({ status: 503, why: expect.stringMatching(/full/) });
105  });
106});
107
108describe('Room: one performance, heard the same by everyone', () => {
109  const decision = { status: 'answered', values: { section: { type: 'choice', choice: 'drop' } } };
110
111  it('passes the host’s run, clock and decisions to guests, and keeps them for a late joiner', async () => {
112    const { join, say } = setup();
113    const host = (await join('host')) as Fake;
114    const early = (await join('guest')) as Fake;
115    expect(last(host, 'people')).toEqual({ t: 'people', n: 2, host: true });
116
117    await say(host, { t: 'run', song: 'jev/lightning-in-a-bottle', code: 'abc' });
118    const run = last(host, 'run').run;
119    expect(last(early, 'run')).toEqual({ t: 'run', run, song: 'jev/lightning-in-a-bottle', code: 'abc' });
120    await say(host, { t: 'decision', run, view: 0, segment: 0, d: decision });
121    await say(host, { t: 'clock', run, startedAt: 5000, cps: 0.75 });
122    await say(host, { t: 'decision', run, view: 1, segment: 1, d: decision });
123    expect(early.got.filter((m) => m.t === 'decision').map((m) => [m.view, m.segment])).toEqual([
124      [0, 0],
125      [1, 1],
126    ]);
127    expect(last(early, 'clock')).toEqual({ t: 'clock', run, startedAt: 5000, cps: 0.75 });
128
129    const late = (await join('guest')) as Fake;
130    const welcome = last(late, 'welcome');
131    expect(welcome).toMatchObject({ run: { run, song: 'jev/lightning-in-a-bottle' }, clock: { startedAt: 5000 } });
132    expect(welcome.decisions).toEqual([
133      { run, view: 0, segment: 0, d: decision },
134      { run, view: 1, segment: 1, d: decision },
135    ]);
136    // the host never hears its own messages back, except the run's number
137    expect(host.got.filter((m) => m.t === 'decision')).toEqual([]);
138  });
139
140  it('accepts a performance only from the host, and only for the current run', async () => {
141    const { join, say } = setup();
142    const host = (await join('host')) as Fake;
143    const guest = (await join('guest')) as Fake;
144    await say(guest, { t: 'run', song: 'jev/x', code: '' });
145    expect(last(host, 'run')).toBeUndefined();
146    await say(host, { t: 'run', song: 'jev/x', code: '' });
147    const run = last(host, 'run').run;
148    await say(guest, { t: 'decision', run, view: 0, segment: 0, d: decision });
149    await say(host, { t: 'decision', run: run + 1, view: 0, segment: 0, d: decision });
150    await say(host, { t: 'decision', run, view: 99, segment: 0, d: decision });
151    await say(host, { t: 'decision', run, view: 0, segment: 1000, d: decision });
152    await say(host, { t: 'run', song: 'not a song', code: '' });
153    expect(guest.got.filter((m) => m.t === 'decision' || (m.t === 'run' && m.song !== 'jev/x'))).toEqual([]);
154  });
155
156  it('starts each performance clean: a new run drops the last one’s decisions, clock and reactions', async () => {
157    const { join, say, storage } = setup();
158    const host = (await join('host')) as Fake;
159    await say(host, { t: 'run', song: 'jev/a', code: '' });
160    let run = last(host, 'run').run;
161    await say(host, { t: 'decision', run, view: 0, segment: 0, d: decision });
162    await say(host, { t: 'clock', run, startedAt: 1, cps: 1 });
163    await say(host, { t: 'react', segment: 0, kind: 'fire' });
164    await say(host, { t: 'run', song: 'jev/b', code: '' });
165    run = last(host, 'run').run;
166    expect(run).toBe(2);
167    expect([...storage.map.keys()].sort()).toEqual(['host', 'run']);
168  });
169
170  it('counts reactions from everyone and tells everyone the totals', async () => {
171    const { join, say } = setup();
172    const host = (await join('host')) as Fake;
173    const a = (await join('guest')) as Fake;
174    const b = (await join('guest')) as Fake;
175    await say(a, { t: 'react', segment: 2, kind: 'fire' });
176    expect(last(host, 'reactions')).toBeUndefined(); // nothing is playing yet
177    await say(host, { t: 'run', song: 'jev/a', code: '' });
178    await say(a, { t: 'react', segment: 2, kind: 'fire' });
179    await say(b, { t: 'react', segment: 2, kind: 'fire' });
180    await say(b, { t: 'react', segment: 2, kind: 'sleep' });
181    await say(b, { t: 'react', segment: 2, kind: 'boo' });
182    for (const p of [host, a, b]) expect(last(p, 'reactions')).toEqual({ t: 'reactions', segment: 2, fire: 2, sleep: 1 });
183    const c = (await join('guest')) as Fake;
184    expect(last(c, 'welcome').reactions).toEqual({ 2: { fire: 2, sleep: 1 } });
185  });
186
187  it('answers a ping with the room’s clock', async () => {
188    const { join, say } = setup();
189    const host = (await join('host')) as Fake;
190    await say(host, { t: 'ping', c: 42 });
191    expect(last(host, 'pong')).toEqual({ t: 'pong', c: 42, s: 1_000_000 });
192  });
193
194  it('tells guests when the host stops, and when the host leaves and returns', async () => {
195    const { join, leave, say } = setup();
196    const host = (await join('host')) as Fake;
197    const guest = (await join('guest')) as Fake;
198    await say(host, { t: 'run', song: 'jev/a', code: '' });
199    const run = last(host, 'run').run;
200    await say(host, { t: 'clock', run, startedAt: 1, cps: 1 });
201    await say(host, { t: 'end', run });
202    expect(last(guest, 'end')).toEqual({ t: 'end', run });
203    await leave(host);
204    expect(last(guest, 'people')).toEqual({ t: 'people', n: 1, host: false });
205    const back = (await join('host')) as Fake;
206    expect(last(back, 'welcome')).toMatchObject({ run: { run }, clock: null });
207    expect(last(guest, 'people')).toEqual({ t: 'people', n: 2, host: true });
208  });
209
210  it('never holds a broadcast for a write to reach disk', async () => {
211    const { join, leave, say, storage } = setup();
212    const host = (await join('host')) as Fake;
213    const guest = (await join('guest')) as Fake;
214    await say(host, { t: 'run', song: 'jev/a', code: '' });
215    const run = last(host, 'run').run;
216    await say(host, { t: 'clock', run, startedAt: 1, cps: 1 });
217    await say(host, { t: 'decision', run, view: 0, segment: 0, d: { status: 'answered' } });
218    await say(guest, { t: 'react', segment: 0, kind: 'fire' });
219    await say(host, { t: 'end', run });
220    await leave(guest);
221    await leave(host);
222    expect(storage.confirmed).toBe(0);
223  });
224
225  it('forgets the party when the last person leaves', async () => {
226    const { join, leave, say, storage } = setup();
227    const host = (await join('host')) as Fake;
228    const guest = (await join('guest')) as Fake;
229    await say(host, { t: 'run', song: 'jev/a', code: '' });
230    await leave(host);
231    expect(storage.map.size).toBeGreaterThan(0);
232    await leave(guest);
233    expect(storage.map.size).toBe(0);
234    // and a room found holding a stale party with nobody in it starts clean
235    storage.map.set('host', 'stale');
236    expect(await join('host', 'a-new-host-0123456789')).toHaveProperty('got');
237  });
238});
239
240describe('Room: what it tells the party directory', () => {
241  it('reports its people and song as they change, and nothing once empty', async () => {
242    const { join, leave, say, reports } = setup();
243    const host = (await join('host')) as Fake;
244    expect(reports.at(-1)).toEqual({ people: 1, host: true, song: null, listed: false });
245    const guest = (await join('guest')) as Fake;
246    expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: false });
247    await say(host, { t: 'run', song: 'jev/a', code: '' });
248    expect(reports.at(-1)).toEqual({ people: 2, host: true, song: 'jev/a', listed: false });
249    await leave(host);
250    expect(reports.at(-1)).toEqual({ people: 1, host: false, song: 'jev/a', listed: false });
251    await leave(guest);
252    expect(reports.at(-1)).toBeNull();
253    // never the room's name or its host's key
254    expect(JSON.stringify(reports)).not.toContain(HOST_KEY);
255  });
256});
257
258describe('Room: a listed party', () => {
259  it('says it is listed when its host chose to be (party.ts stores the choice)', async () => {
260    const { join, reports, storage } = setup();
261    await join('host');
262    storage.map.set('listed', true);
263    await join('guest');
264    expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: true });
265  });
266
267  it('leaves and rejoins the list at once when its host changes "show me here" mid-party, guests staying in', async () => {
268    const { join, say, reports, storage } = setup();
269    const host = (await join('host')) as Fake;
270    const guest = (await join('guest')) as Fake;
271    storage.map.set('listed', true);
272    storage.map.set('name', OWN_ROOM);
273    await say(host, { t: 'listed', listed: false });
274    expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: false });
275    expect(storage.map.has('name')).toBe(false);
276    expect(guest.closed).toBeUndefined();
277    await say(host, { t: 'listed', listed: true, room: OWN_ROOM });
278    expect(reports.at(-1)).toEqual({ people: 2, host: true, song: null, listed: true });
279    expect(storage.map.get('name')).toBe(OWN_ROOM);
280    // the report never carries the name
281    expect(JSON.stringify(reports)).not.toContain(OWN_ROOM);
282  });
283
284  it("takes a name only when it is the room's own, and the choice only from the host", async () => {
285    const { join, say, reports, storage } = setup();
286    const host = (await join('host')) as Fake;
287    const guest = (await join('guest')) as Fake;
288    const before = reports.length;
289    await say(host, { t: 'listed', listed: true, room: 'someone-elses-room-0123' });
290    await say(host, { t: 'listed', listed: true });
291    await say(guest, { t: 'listed', listed: true, room: OWN_ROOM });
292    await say(host, { t: 'listed', listed: 'yes', room: OWN_ROOM });
293    expect(reports.length).toBe(before);
294    expect(storage.map.has('name')).toBe(false);
295    expect(storage.map.get('listed')).toBeUndefined();
296  });
297});
298
299describe('Room: limits', () => {
300  it(`refuses a message over ${MAX_MESSAGE_BYTES} bytes, and binary`, async () => {
301    const { join, room } = setup();
302    const host = (await join('host')) as Fake;
303    await room.message(host, JSON.stringify({ t: 'ping', c: 1, pad: 'x'.repeat(MAX_MESSAGE_BYTES) }));
304    expect(last(host, 'pong')).toBeUndefined();
305    expect(last(host, 'error').why).toMatch(/at most/);
306    await room.message(host, new ArrayBuffer(8));
307    expect(host.got.filter((m) => m.t === 'error')).toHaveLength(2);
308  });
309
310  it(`drops messages past a burst of ${BURST}, warns once, and refills with time`, async () => {
311    const { join, say, tick } = setup();
312    const host = (await join('host')) as Fake;
313    for (let i = 0; i < BURST + 10; i++) await say(host, { t: 'ping', c: i });
314    expect(host.got.filter((m) => m.t === 'pong')).toHaveLength(BURST);
315    expect(host.got.filter((m) => m.t === 'error')).toHaveLength(1);
316    tick(1000);
317    await say(host, { t: 'ping', c: 999 });
318    expect(last(host, 'pong').c).toBe(999);
319  });
320});
321
322describe('party: the route into a room', () => {
323  const ROOM = 'Zm9vYmFyYmF6cXV4MTIzNA';
324  const env = (success = true) => {
325    const reached: string[] = [];
326    return {
327      reached,
328      PARTY_LIMIT: { limit: async () => ({ success }) },
329      PARTY: {
330        idFromName: (name: string) => name,
331        get: (id: string) => ({ fetch: async () => (reached.push(id), new Response('room')) }),
332      },
333    };
334  };
335  const ws = (path: string, upgrade = true) =>
336    new Request(`https://jevstrudel.example${path}`, { headers: upgrade ? { Upgrade: 'websocket' } : {} });
337
338  it('reaches the room named in the path, one Durable Object per room', async () => {
339    const e = env();
340    expect(await (await party(ws(`/jev/party/${ROOM}?role=guest`), e as never)).text()).toBe('room');
341    expect(e.reached).toEqual([ROOM]);
342  });
343
344  it('refuses a malformed room, a plain request, and a visitor over the limit', async () => {
345    expect((await party(ws('/jev/party/short'), env() as never)).status).toBe(404);
346    expect((await party(ws('/jev/party/../../x'), env() as never)).status).toBe(404);
347    expect((await party(ws(`/jev/party/${ROOM}`, false), env() as never)).status).toBe(426);
348    expect((await party(ws(`/jev/party/${ROOM}`), env(false) as never)).status).toBe(429);
349  });
350});