File
Blob: worker/tests/runtime/publisher.test.ts
| 1 | import { expect, test } from 'vitest'; |
| 2 | import { createHarness, device, offer, track, nowPlaying } from './harness.ts'; |
| 3 | |
| 4 | for (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 | |
| 40 | test('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 | |
| 77 | test('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 | |
| 121 | test('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 | |
| 151 | test('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 | }); |