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

The lobby (lobby-room.ts), with fake sockets whose attachments live in memory as the Durable Object's would.

3import { describe, expect, it } from 'vitest';
4import { BURST, Lobby, MAX_MESSAGE_BYTES, MAX_TITLE, statusOf, type Attachment, type Peer } from './lobby-room';
6type Fake = Peer & { got: any[]; a: Attachment };
7function setup() {
8  const peers: Fake[] = [];
9  let now = 1_000_000;
10  const lobby = new Lobby(() => peers, () => now);
11  const join = (id: string, name: string | null = null, group = id): Fake => {
12    const got: any[] = [];
13    const p: Fake = {
14      got,
15      a: { id, name, group, visible: false, changed: now, status: null },
16      send: (m: string) => void got.push(JSON.parse(m)),
17      close() {},
18      deserializeAttachment() {
19        return structuredClone(this.a);
20      },
21      serializeAttachment(v) {
22        this.a = structuredClone(v as Attachment);
23      },
24    };
25    peers.push(p);
26    lobby.joined(p);
27    return p;
28  };
29  const leave = (p: Fake) => {
30    lobby.left(p);
31    peers.splice(peers.indexOf(p), 1);
32  };
33  const say = (p: Fake, m: unknown) => lobby.message(p, JSON.stringify(m));
34  const last = (p: Fake, t: string) => [...p.got].reverse().find((m) => m.t === t);
35  return { lobby, join, leave, say, last, tick: (ms: number) => (now += ms), now: () => now };
36}
37
38const playing = { t: 'status', visible: true, song: 'jev/system-one', playing: true, cycle: 12, cps: 0.5, length: 64 };
39
40describe('the lobby', () => {
41  it('welcomes a tab with its id, the room’s clock and every visible tab', () => {
42    const { join, say, now } = setup();
43    const a = join('tab-aaaaaaaa', 'Chris', 'g1');
44    say(a, playing);
45    join('tab-hidden01');
46    const c = join('tab-cccccccc');
47    const welcome = c.got[0];
48    expect(welcome).toMatchObject({ t: 'welcome', id: 'tab-cccccccc', now: now(), n: 3 });
49    // a tab that has said nothing yet, like a hidden one, is not listed
50    expect(welcome.tabs).toEqual([
51      expect.objectContaining({ id: 'tab-aaaaaaaa', name: 'Chris', group: 'g1', song: 'jev/system-one', cycle: 12, length: 64 }),
52    ]);
53  });
54
55  it('tells everyone else when a tab changes, is hidden, or leaves, and how many are here', () => {
56    const { join, leave, say, last } = setup();
57    const a = join('tab-aaaaaaaa');
58    const b = join('tab-bbbbbbbb');
59    expect(last(a, 'count')).toEqual({ t: 'count', n: 2 });
60    say(b, playing);
61    expect(last(a, 'tab').tab).toMatchObject({ id: 'tab-bbbbbbbb', name: null, playing: true });
62    expect(b.got.some((m) => m.t === 'tab')).toBe(false);
63    say(b, { ...playing, visible: false });
64    expect(last(a, 'gone')).toEqual({ t: 'gone', id: 'tab-bbbbbbbb' });
65    say(b, playing);
66    a.got.length = 0;
67    leave(b);
68    expect(a.got).toEqual([{ t: 'gone', id: 'tab-bbbbbbbb' }, { t: 'count', n: 1 }]);
69  });
70
71  it('passes on only what it checked: bounded text, known shapes, no progress while stopped', () => {
72    const s = statusOf(
73      {
74        song: '../etc',
75        listener: 'abc_DEF-1',
76        title: `a\u0000${'x'.repeat(200)}`,
77        edited: true,
78        playing: false,
79        cycle: 5,
80        cps: 1,
81        length: -3,
82        section: '<b>drop</b>',
83        extra: 'dropped',
84      },
85      7,
86    );
87    expect(s).toEqual({
88      song: null,
89      listener: 'abc_DEF-1',
90      title: `a ${'x'.repeat(MAX_TITLE - 2)}`,
91      edited: false,
92      playing: false,
93      cycle: null,
94      cps: null,
95      at: 7,
96      length: null,
97      max: null,
98      section: '<b>drop</b>', // text; pages render it as text
99    });
100    expect(statusOf({ song: 'jev/a', listener: 'x', title: 't', edited: true }, 0)).toMatchObject({
101      song: 'jev/a',
102      listener: null,
103      title: null,
104      edited: true,
105    });
106  });
107
108  it('refuses oversized messages and drops a flood past its bucket', () => {
109    const { join, say, last } = setup();
110    const a = join('tab-aaaaaaaa');
111    const b = join('tab-bbbbbbbb');
112    a.got.length = 0;
113    b.got.length = 0;
114    say(b, { ...playing, title: 'x'.repeat(MAX_MESSAGE_BYTES) });
115    expect(last(b, 'error').why).toMatch(/at most/);
116    for (let i = 0; i < BURST + 10; i++) say(b, { ...playing, cycle: i });
117    // the refused message cost nothing; the flood gets the whole bucket
118    expect(a.got.filter((m) => m.t === 'tab').length).toBe(BURST);
119  });
120
121  it('brokers listening along: the asker gets the party’s room, and nobody else does', () => {
122    const { join, say, last } = setup();
123    const host = join('tab-hosthost');
124    const asker = join('tab-askerask');
125    const other = join('tab-otherrrr');
126    say(host, playing);
127    say(asker, { t: 'listen', to: 'tab-hosthost' });
128    expect(last(host, 'listen')).toEqual({ t: 'listen', from: 'tab-askerask' });
129    say(host, { t: 'invite', to: 'tab-askerask', room: 'room-0123456789abcdef' });
130    expect(last(asker, 'join')).toEqual({ t: 'join', from: 'tab-hosthost', room: 'room-0123456789abcdef' });
131    expect(other.got.some((m) => m.t === 'join' || m.t === 'listen')).toBe(false);
132    // answered once: a second invite does not reach it
133    asker.got.length = 0;
134    say(host, { t: 'invite', to: 'tab-askerask', room: 'room-0123456789abcdef' });
135    expect(asker.got).toEqual([]);
136  });
137
138  it('sends nobody into a party they did not ask for', () => {
139    const { join, say } = setup();
140    const mallory = join('tab-mallory1');
141    const victim = join('tab-victim01');
142    say(mallory, playing);
143    say(mallory, { t: 'invite', to: 'tab-victim01', room: 'room-0123456789abcdef' });
144    expect(victim.got.some((m) => m.t === 'join')).toBe(false);
145  });
146
147  it('asks only a visible tab playing a site song', () => {
148    const { join, say, last } = setup();
149    const a = join('tab-aaaaaaaa');
150    const b = join('tab-bbbbbbbb');
151    say(b, { ...playing, song: null, listener: 'song1', title: 'a listener song' });
152    say(a, { t: 'listen', to: 'tab-bbbbbbbb' });
153    expect(last(a, 'error').why).toMatch(/site song/);
154    say(b, { ...playing, visible: false });
155    say(a, { t: 'listen', to: 'tab-bbbbbbbb' });
156    expect(last(a, 'error').why).toMatch(/not listed/);
157    expect(b.got.some((m) => m.t === 'listen')).toBe(false);
158  });
159});