// End-to-end checks of the realtime protocol through the REAL // SocketGateway + PartyService + MusicQueueService together (not just one // service in isolation) - a fake io/socket pair standing in for Socket.IO // itself, since a real client<->server round trip needs an actual HTTP // upgrade. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { PartyService } from '../src/services/PartyService'; import { MusicQueueService } from '../src/services/MusicQueueService'; import { SocketGateway } from '../src/realtime/SocketGateway'; import type { AppServer, AppSocket } from '../src/types'; type Handler = (payload?: unknown) => void; function fakeSocket(cookie: string) { const handlers = new Map(); const emitted: [string, unknown][] = []; return { request: { headers: { cookie } }, emitted, on(event: string, cb: Handler) { const list = handlers.get(event) || []; list.push(cb); handlers.set(event, list); }, emit(event: string, payload?: unknown) { emitted.push([event, payload]); }, to() { return { emit() {} }; }, join() {}, disconnect() {}, trigger(event: string, payload?: unknown) { (handlers.get(event) || []).forEach((cb) => cb(payload)); } }; } function fakeIo() { const handlers = new Map void)[]>(); const roomEmits: { room: string; event: string; payload: unknown }[] = []; return { roomEmits, on(event: string, cb: (...args: unknown[]) => void) { const list = handlers.get(event) || []; list.push(cb); handlers.set(event, list); }, to(room: string) { return { emit: (event: string, payload: unknown) => roomEmits.push({ room, event, payload }) }; }, trigger(event: string, ...args: unknown[]) { (handlers.get(event) || []).forEach((cb) => cb(...args)); } }; } function attach(io: ReturnType) { SocketGateway.attach( io as unknown as AppServer, (raw) => ({ wp_session: raw as string }), (sessionId) => ({ userId: sessionId, userName: sessionId }) ); MusicQueueService.attachIo(io as unknown as AppServer, () => {}); } describe('SocketGateway', () => { it('voteSkip/musicPlaystate are no-ops on a video party', () => { const io = fakeIo(); attach(io); const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video'); const socket = fakeSocket('u-host'); io.trigger('connection', socket as unknown as AppSocket); socket.trigger('join', { partyId: party.id }); const before = io.roomEmits.length; socket.trigger('voteSkip'); socket.trigger('musicPlaystate', { isPaused: true }); assert.equal(io.roomEmits.length, before, 'neither event should produce any broadcast on a video party'); }); it('a non-host member can pause and resume a music party directly (no host check)', () => { const io = fakeIo(); attach(io); const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); PartyService.join(party, 'u-bob', 'Bob'); const bobSocket = fakeSocket('u-bob'); io.trigger('connection', bobSocket as unknown as AppSocket); bobSocket.trigger('join', { partyId: party.id }); assert.notEqual(party.hostUserId, 'u-bob'); bobSocket.trigger('musicPlaystate', { isPaused: true }); assert.equal(party.music?.current?.isPaused, true); bobSocket.trigger('musicPlaystate', { isPaused: false }); assert.equal(party.music?.current?.isPaused, false); }); it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => { const io = fakeIo(); attach(io); const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); PartyService.join(party, 'u-bob', 'Bob'); const aliceSocket = fakeSocket('u-alice'); io.trigger('connection', aliceSocket as unknown as AppSocket); aliceSocket.trigger('join', { partyId: party.id }); const bobSocket = fakeSocket('u-bob'); io.trigger('connection', bobSocket as unknown as AppSocket); bobSocket.trigger('join', { partyId: party.id }); bobSocket.trigger('voteSkip'); assert.equal(party.music?.current?.track.itemId, 't1', 'one of two connected should not skip yet'); io.roomEmits.length = 0; aliceSocket.trigger('voteSkip'); assert.equal(party.music?.current, null, 'the second vote reaches the majority (no second track queued)'); assert.ok(io.roomEmits.some((e) => e.room === party.id && e.event === 'queueUpdated')); }); it('leaving hands the host off live and refreshes the lobby', () => { const io = fakeIo(); attach(io); const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); PartyService.join(party, 'u-bob', 'Bob'); const bobSocket = fakeSocket('u-bob'); io.trigger('connection', bobSocket as unknown as AppSocket); bobSocket.trigger('join', { partyId: party.id }); const aliceSocket = fakeSocket('u-alice'); io.trigger('connection', aliceSocket as unknown as AppSocket); aliceSocket.trigger('join', { partyId: party.id }); aliceSocket.trigger('disconnect'); assert.equal(party.hostUserId, 'u-bob'); assert.ok(bobSocket.emitted.some(([event]) => event === 'hostChanged'), 'the new host should get a personal notification'); }); it('playstate logs only on an actual pause/play transition, never on an unchanged heartbeat resend', (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); const io = fakeIo(); attach(io); const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); const socket = fakeSocket('u-alice'); io.trigger('connection', socket as unknown as AppSocket); socket.trigger('join', { partyId: party.id }); const playstateLines = () => logged.filter((l) => l.includes(' playstate ')); // The initial state is a real transition (a new member defaults to // isPaused:true - see PartyService's newMember) - this is "the party // started playing", and should log. socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 }); assert.equal(playstateLines().length, 1); // 5 heartbeats, same isPaused, only position moving - none of these // should add a line (the player re-sends its current state every 3s // regardless of whether anything changed - see docs/architecture.md). for (let i = 0; i < 5; i++) socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 + i * 30_000_000 }); assert.equal(playstateLines().length, 1); socket.trigger('playstate', { isPaused: true, positionTicks: 160_000_000 }); assert.equal(playstateLines().length, 2); for (let i = 0; i < 3; i++) socket.trigger('playstate', { isPaused: true, positionTicks: 160_000_000 }); assert.equal(playstateLines().length, 2); socket.trigger('playstate', { isPaused: false, positionTicks: 160_000_000 }); assert.equal(playstateLines().length, 3); }); it('requestPause is throttled per member (5s) even against a raw repeat', (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); const io = fakeIo(); attach(io); const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); PartyService.join(party, 'u-bob', 'Bob'); const bobSocket = fakeSocket('u-bob'); io.trigger('connection', bobSocket as unknown as AppSocket); bobSocket.trigger('join', { partyId: party.id }); bobSocket.trigger('requestPause'); bobSocket.trigger('requestPause'); const pauseRequestLines = logged.filter((l) => l.includes(' pauseRequested ')); assert.equal(pauseRequestLines.length, 1, 'an immediate repeat must not produce a second line'); }); });