Skip to content
File

Blob: worker/tests/runtime/publisher.test.ts

typescript171 lines
1import { expect, test } from 'vitest';
2import { createHarness, device, offer, track, nowPlaying } from './harness.ts';
3 
4for (const [name, fields, conflictStatus] of [
5 ['legacy', { track }, 409],
6 ['playlist only', { nowPlaying }, 400],
7 ['both', { track, nowPlaying }, 400],
8] as const) {
9 test(`${name} startup is idempotent across concurrent calls and eviction`, async () => {
10 const h = await createHarness();
11 const input = { sessionDescription: offer, bootId: 'a'.repeat(32), ...fields };
12 const [first, second] = await Promise.all([
13 h.call('/device/start', input, device),
14 h.call('/device/start', input, device),
15 ]);
16 expect(first.status).toBe(200);
17 expect(second.status).toBe(200);
18 const publisher = await first.json();
19 expect(await second.json()).toEqual(publisher);
20 expect(h.allocations()).toBe(1);
21 await h.evict();
22 expect(await (await h.call('/device/start', input, device)).json()).toEqual(publisher);
23 expect(h.allocations()).toBe(1);
24 expect(
25 (
26 await h.call(
27 '/device/start',
28 { ...input, sessionDescription: { ...offer, sdp: offer.sdp + 'a=x:changed\r\n' } },
29 device,
30 )
31 ).status,
32 ).toBe(409);
33 expect(
34 (await h.call('/device/start', { ...input, track: { ...track, title: 'Changed' } }, device))
35 .status,
36 ).toBe(conflictStatus);
37 });
38}
39 
40test('metadata follows song changes without reallocating sessions and preserves startup identity', async () => {
41 const h = await createHarness();
42 await h.login();
43 const { input, publisher, identity } = await h.start({ nowPlaying });
44 const viewer = await h.viewer();
45 const before = h.calls.length;
46 const next = { ...nowPlaying, revision: 1, trackIndex: 1, track: { ...track, title: 'Next' } };
47 expect(
48 (await h.call('/device/heartbeat', { ...identity, nowPlaying: next }, device)).status,
49 ).toBe(200);
50 let state = await h.status();
51 expect(state.nowPlaying).toEqual(next);
52 expect(state.generation).toBe(publisher.generation);
53 expect(state.viewers).toBe(1);
54 expect(h.calls.length).toBe(before);
55 await h.call('/device/heartbeat', { ...identity, nowPlaying }, device);
56 expect((await h.status()).nowPlaying).toEqual(next);
57 expect(
58 (
59 await h.call(
60 '/device/heartbeat',
61 { ...identity, nowPlaying: { ...next, trackIndex: 2 } },
62 device,
63 )
64 ).status,
65 ).toBe(409);
66 await h.evict();
67 expect((await h.status()).nowPlaying).toEqual(next);
68 expect(await (await h.call('/device/start', input, device)).json()).toEqual(publisher);
69 expect((await h.call(`/viewers/${viewer.id}/heartbeat`, {}, viewer.owner)).status).toBe(200);
70 await h.start({ bootId: 'b'.repeat(32) });
71 state = await h.status();
72 expect(state.track).toBe(null);
73 expect(state.nowPlaying).toBe(null);
74 expect((await h.call('/device/heartbeat', identity, device)).status).toBe(409);
75});
76 
77test('a replacement boot discards the old generation without contacting its SFU sessions', async () => {
78 let obsolete = false;
79 const oldSession = /\/sessions\/session-[12](?:\/|$)/;
80 const h = await createHarness(({ path }) =>
81 obsolete && oldSession.test(path)
82 ? Response.json({ errorCode: 'internal_error' }, { status: 503 })
83 : undefined,
84 );
85 await h.login();
86 const previous = await h.start();
87 const oldViewer = await h.viewer();
88 expect((await h.call(`/viewers/${oldViewer.id}/claim`, {}, oldViewer.owner)).status).toBe(200);
89 await h.evict();
90 obsolete = true;
91 const before = h.calls.length;
92 const replacement = await h.start({ bootId: 'b'.repeat(32) });
93 expect(replacement.identity.generation).not.toBe(previous.identity.generation);
94 expect(await h.status()).toMatchObject({
95 ...replacement.identity,
96 online: true,
97 viewers: 0,
98 controller: null,
99 });
100 expect(h.calls.slice(before).some((call) => oldSession.test(call.path))).toBe(false);
101 const currentViewer = await h.viewer();
102 expect((await h.call(`/viewers/${currentViewer.id}/claim`, {}, currentViewer.owner)).status).toBe(
103 200,
104 );
105 const after = h.calls.length;
106 for (const operation of ['heartbeat', 'channels', 'ready'])
107 expect((await h.call(`/device/${operation}`, previous.identity, device)).status).toBe(409);
108 for (const operation of ['heartbeat', 'claim', 'release', 'leave', 'audio'])
109 expect(
110 (await h.call(`/viewers/${oldViewer.id}/${operation}`, {}, oldViewer.owner)).status,
111 ).toBe(403);
112 expect(h.calls).toHaveLength(after);
113 expect((await h.status()).controller).toBe(currentViewer.id);
114 await h.evict();
115 expect(await (await h.call('/device/start', replacement.input, device)).json()).toEqual(
116 replacement.publisher,
117 );
118 expect(h.allocations()).toBe(4);
119});
120 
121test('failed replacement allocation cannot restore the retired generation after eviction', async () => {
122 let failAllocation = false;
123 const h = await createHarness(({ path }) =>
124 failAllocation && path.endsWith('/sessions/new')
125 ? Response.json({ errorCode: 'temporarily_unavailable_error' }, { status: 503 })
126 : undefined,
127 );
128 await h.login();
129 const previous = await h.start();
130 const oldViewer = await h.viewer();
131 expect((await h.call(`/viewers/${oldViewer.id}/claim`, {}, oldViewer.owner)).status).toBe(200);
132 failAllocation = true;
133 const input = { sessionDescription: offer, bootId: 'b'.repeat(32) };
134 expect((await h.call('/device/start', input, device)).status).toBe(502);
135 await h.evict();
136 expect(await h.status()).toMatchObject({
137 online: false,
138 generation: null,
139 viewers: 0,
140 controller: null,
141 });
142 expect((await h.call('/device/heartbeat', previous.identity, device)).status).toBe(409);
143 expect((await h.call(`/viewers/${oldViewer.id}/heartbeat`, {}, oldViewer.owner)).status).toBe(
144 403,
145 );
146 failAllocation = false;
147 await h.start(input);
148 expect((await h.status()).online).toBe(true);
149});
150 
151test('an invalid replacement offer leaves the current generation usable', async () => {
152 const h = await createHarness();
153 await h.login();
154 const previous = await h.start();
155 const viewer = await h.viewer();
156 const before = h.calls.length;
157 expect(
158 (
159 await h.call(
160 '/device/start',
161 { sessionDescription: { ...offer, sdp: 'v=0\r\n' }, bootId: 'b'.repeat(32) },
162 device,
163 )
164 ).status,
165 ).toBe(400);
166 expect(h.calls).toHaveLength(before);
167 expect((await h.call('/device/heartbeat', previous.identity, device)).status).toBe(200);
168 expect((await h.call(`/viewers/${viewer.id}/heartbeat`, {}, viewer.owner)).status).toBe(200);
169 expect(await h.status()).toMatchObject({ ...previous.identity, online: true, viewers: 1 });
170});