watchparty/server/test/socketGateway.test.ts
Nicolas daab755ce9
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 18s
Device detection: confirm before disconnecting another device
Asked for directly: "same account, same device already in the party"
should never need a confirmation, "same account, a genuinely different
device" should ask before disconnecting it instead of silently
overwriting. There was no concept of "device" at all before this -
party.members is keyed by userId only.

- client/src/deviceId.ts (new): a UUID kept in localStorage (not
  sessionStorage) - that's what makes "device" mean "browser", not
  "tab". Falls back to an in-memory id if storage is unavailable.
- server/src/types.ts, client/src/api/party.ts: "join" now carries
  {partyId, deviceId, force?} instead of just {partyId}; new
  PartyMember.deviceId and "deviceConflict" server->client event (sent
  only to the joining socket, never broadcast).
- server/src/realtime/SocketGateway.ts: the "join" handler now tells
  three cases apart - no existing connection or the same deviceId
  reconnecting (attach silently, booting any stale connection for that
  same device first), a different deviceId with no force (emit
  deviceConflict and stop - the socket stays connected but inert), or
  force:true (the confirmed takeover - boot the old connection, take
  the slot).
- server/src/services/PartyService.ts: join() is now idempotent -
  returns the existing member instead of always building a fresh one.
  This was itself a real bug independent of the guard below: the REST
  "Rejoindre" route calls this every time regardless of membership, so
  a re-click/reload/retry used to silently reset connection/isPaused/
  deviceId.
- server/src/realtime/SocketGateway.ts: "leave"/"disconnect" now also
  check party.members.get(userId)?.connection === socket before acting
  - generalizes the earlier stale-connection crash fix to the new
  reality where a member can have more than one connection attempt in
  flight (the superseded one's own eventual "disconnect" must not be
  read as a real leave).
- client/src/views/player/Player.ts, client/src/musicBar/MusicBar.ts:
  onDeviceConflict wired to a plain confirm() - yes calls the new
  PartyConnection.confirmDeviceTakeover() (re-sends "join" with
  force:true), no backs out (navigate home for video, leaveMusicParty
  for music).
- Deliberately does NOT change the home page's live party list itself
  (still just "Rejoindre" either way) - LobbyPartyDto is one snapshot
  shared by the whole "lobby" room with no per-viewer membership info,
  and giving it that is a real protocol change on its own. For video
  the conflict check above already is the meaningful moment (you can't
  be looking at the home page from inside your own party on the same
  tab - the player is a full-screen takeover), so this doesn't cost
  the feature much: Rejoindre reaches the check immediately, same
  device reconnects silently, a different device asks first. See
  docs/architecture.md for the fuller reasoning.
- server/test/socketGateway.test.ts, server/test/partyService.test.ts:
  new tests for the conflict/takeover/reconnect paths and join()'s
  idempotency; the earlier stale-connection crash regression test
  updated to reach the same shared-slot shape through a real device
  takeover instead of a raw PartyService.join() call (which no longer
  reproduces it now that join() is idempotent).

Verified: full typecheck+test+build on both projects (39/39 server
tests passing); confirmed the "leave"/"disconnect" guard is load-
bearing by temporarily reverting it and watching the new takeover test
fail, then restoring it; deviceId.ts's localStorage persistence
checked against the real compiled bundle in a real browser (survives
a reload, stable across calls).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 17:32:21 +02:00

368 lines
18 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('requestResume is throttled independently from requestPause (its own 5s window, own counter)', (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('requestResume');
bobSocket.trigger('requestResume');
assert.equal(logged.filter((l) => l.includes(' pauseRequested ')).length, 1);
assert.equal(logged.filter((l) => l.includes(' resumeRequested ')).length, 1, 'an immediate repeat must not produce a second line, and requestPause just above must not have consumed this budget');
});
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');
});
it('a stale connection\'s next heartbeat after a device takeover destroys the party does not crash the process (regression - see docs/architecture.md)', (t) => {
const crashed: unknown[] = [];
t.mock.method(console, 'error', (..._args: unknown[]) => { crashed.push(_args); });
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const socketA = fakeSocket('u-alice');
io.trigger('connection', socketA as unknown as AppSocket);
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
// A confirmed device takeover (see the "join" handler below) hands
// socketA's map slot to socketB - the exact "two connections for the
// same userId share one slot" shape the original crash needed, now
// reached through the real takeover flow instead of a raw
// PartyService.join() call (that method is idempotent now - see its
// own test in partyService.test.ts - so a second call to it would no
// longer reproduce this).
const socketB = fakeSocket('u-alice');
io.trigger('connection', socketB as unknown as AppSocket);
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B', force: true });
// socketB (currently occupying the slot) leaves - the only member, so
// the party is destroyed; hostUserId is never reassigned since
// there's no successor to hand off to (see PartyService.leave).
socketB.trigger('leave');
assert.equal(PartyService.getById(party.id), null, 'sanity check: the party really is gone');
// socketA's regular 3s heartbeat fires next, completely unaware any
// of this happened: its own closure still references the same
// (now-destroyed) Party object, and its stale userId still equals
// the stale hostUserId, so the host check alone doesn't catch this -
// party.members.get(userId) is what has to.
assert.doesNotThrow(() => socketA.trigger('playstate', { isPaused: true, positionTicks: 0 }));
assert.equal(crashed.length, 0, 'should be a clean early return (the specific guard), not the generic safe() net silently swallowing an actual throw');
});
it('a second device for the same account gets a deviceConflict instead of silently taking over', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const socketA = fakeSocket('u-alice');
io.trigger('connection', socketA as unknown as AppSocket);
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
const socketB = fakeSocket('u-alice');
io.trigger('connection', socketB as unknown as AppSocket);
const roomEmitsBefore = io.roomEmits.length;
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B' });
assert.ok(socketB.emitted.some(([event]) => event === 'deviceConflict'), 'the new device should be told about the conflict');
assert.equal(party.members.get('u-alice')?.connection, socketA, 'the existing connection must stay in place until the conflict is confirmed');
assert.equal(io.roomEmits.length, roomEmitsBefore, 'a conflicting join must not broadcast "members" - the socket never actually joined');
assert.ok(!socketA.emitted.some(([event]) => event === 'errorMessage'), 'the existing device must not be kicked yet');
});
it('re-joining with force:true confirms the takeover: kicks the old device, attaches the new one', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const socketA = fakeSocket('u-alice');
io.trigger('connection', socketA as unknown as AppSocket);
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
const socketB = fakeSocket('u-alice');
io.trigger('connection', socketB as unknown as AppSocket);
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B' }); // conflict - the client would show a confirm() here
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B', force: true }); // user confirmed
assert.ok(socketA.emitted.some(([event]) => event === 'errorMessage'), 'the old device should be told it got disconnected');
assert.equal(party.members.get('u-alice')?.connection, socketB, 'the new device now holds the slot');
assert.equal(party.members.get('u-alice')?.deviceId, 'device-B');
// The old connection's own eventual "disconnect" - Socket.IO fires this
// automatically after a server-side .disconnect(true), simulated here
// manually like this file's other tests - must NOT be mistaken for a
// real leave: it no longer holds the member's connection slot (see the
// "join" handler's own comment on why, and the guard on "leave"/
// "disconnect" below).
socketA.trigger('disconnect');
assert.equal(PartyService.getById(party.id), party, 'the party must survive the superseded connection\'s disconnect');
assert.ok(party.members.has('u-alice'), 'the member must still be present, still attached to socketB');
});
it('the same device reconnecting (matching deviceId) takes over silently - no deviceConflict', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const socketA = fakeSocket('u-alice');
io.trigger('connection', socketA as unknown as AppSocket);
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
// e.g. a page reload on the same browser: a fresh socket, but the same
// deviceId persisted in localStorage (see client/src/deviceId.ts).
const socketA2 = fakeSocket('u-alice');
io.trigger('connection', socketA2 as unknown as AppSocket);
socketA2.trigger('join', { partyId: party.id, deviceId: 'device-A' });
assert.ok(!socketA2.emitted.some(([event]) => event === 'deviceConflict'), 'the same device should never conflict with itself');
assert.equal(party.members.get('u-alice')?.connection, socketA2);
assert.equal(party.members.get('u-alice')?.deviceId, 'device-A');
});
});