Reported: the time shown for an ongoing party is the movie's elapsed time. True, but only by coincidence - the label was actually just Date.now() - party.createdUtc, which climbs at wall-clock speed regardless of whether the movie is actually playing. Pause it for five minutes and the home page keeps counting through the pause the player itself has frozen. - server/src/types.ts, client/src/types.ts: LobbyPartyDto gains positionTicks/isPaused/positionUpdatedUtc - the host's own tracked playback state (same values SocketGateway already maintains for their party's own room), null for music parties, where there's no single "elapsed" across a rotating queue of tracks. - server/src/realtime/SocketGateway.ts: lobbySnapshot() fills these from the host's member record for video parties. Two new broadcastLobby() trigger points to keep it reasonably fresh: an actual pause/play transition (not every 3s heartbeat resend - same guard already used for that transition's log line) and advancing the queue (advanceQueue already reset every member's position for the new item; nothing had ever pushed that to the lobby before, so a card's title/poster/position could sit stale until an unrelated join/leave refreshed it). - client/src/views/home/Home.ts: updateElapsedLabels() now computes a live, pause-aware position for a video party (frozen while paused, ticking from position+timestamp while playing) instead of always falling back to createdUtc - which is still exactly what a music party (or a party missing these fields) uses. - server/test/socketGateway.test.ts: two new cases - the lobby snapshot's video-vs-music shape, and that a transition/next() refreshes it while an unchanged heartbeat resend doesn't. Verified against the real bundled client with a faked "parties" push: a paused party's label held still across a real wait; a playing one, and a music party on the unchanged createdUtc path, both kept ticking up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
// 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, LobbyPartyDto } from '../src/types';
|
|
|
|
type Handler = (payload?: unknown) => void;
|
|
|
|
function fakeSocket(cookie: string) {
|
|
const handlers = new Map<string, Handler[]>();
|
|
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<string, ((...args: unknown[]) => 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<typeof fakeIo>) {
|
|
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');
|
|
});
|
|
|
|
it('the lobby snapshot exposes the host\'s live position for a video party, and null for a music party', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const videoParty = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const musicParty = PartyService.create('u-bob', 'Bob', 't1', 'Track One', '/img/t1', null, 'music');
|
|
MusicQueueService.init(musicParty, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-bob', 'Bob');
|
|
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: videoParty.id });
|
|
aliceSocket.trigger('playstate', { isPaused: false, positionTicks: 50_000_000 }); // 5s in, playing
|
|
|
|
const subscriber = fakeSocket('u-eve');
|
|
io.trigger('connection', subscriber as unknown as AppSocket);
|
|
subscriber.trigger('subscribeLobby');
|
|
|
|
const parties = subscriber.emitted.find(([event]) => event === 'parties')![1] as LobbyPartyDto[];
|
|
const video = parties.find((p) => p.partyId === videoParty.id)!;
|
|
const music = parties.find((p) => p.partyId === musicParty.id)!;
|
|
|
|
assert.equal(video.positionTicks, 50_000_000);
|
|
assert.equal(video.isPaused, false);
|
|
assert.ok(video.positionUpdatedUtc, 'should carry a timestamp so the client can tick from it');
|
|
|
|
assert.equal(music.positionTicks, null, 'no single "elapsed" makes sense across a rotating music queue');
|
|
assert.equal(music.isPaused, null);
|
|
assert.equal(music.positionUpdatedUtc, null);
|
|
});
|
|
|
|
it('a play/pause transition refreshes the lobby (an unchanged heartbeat resend does not); advancing the queue does too', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
const socket = fakeSocket('u-alice');
|
|
io.trigger('connection', socket as unknown as AppSocket);
|
|
socket.trigger('join', { partyId: party.id });
|
|
|
|
const lobbyRefreshCount = () => io.roomEmits.filter((e) => e.room === 'lobby' && e.event === 'parties').length;
|
|
|
|
// The default new-member state is isPaused:true (see PartyService's
|
|
// newMember) - becoming "playing" is a real transition, so this
|
|
// should refresh the lobby once.
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 });
|
|
assert.equal(lobbyRefreshCount(), 1);
|
|
|
|
// Same isPaused, only position moving - the player's 3s heartbeat
|
|
// resend (see Player.ts) - must NOT refresh the lobby again, same
|
|
// discipline as the "playstate logs only on a transition" test above.
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 40_000_000 });
|
|
assert.equal(lobbyRefreshCount(), 1);
|
|
|
|
socket.trigger('next');
|
|
assert.equal(lobbyRefreshCount(), 2, 'advancing the queue resets position/pause for the new item - the home page needs that immediately, not just whenever some other event next refreshes it');
|
|
});
|
|
});
|