watchparty/server/test/partyService.test.ts
Nicolas 3ae1fce806
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Fix device takeover leaving both devices on the session with no working controls
Two gaps around an already-correct takeover mechanism, not the
mechanism itself. First: Player.ts/MusicBar.ts both wired onClose to a
no-op, so the moment a takeover force-disconnected the old device its
video/hls.js instance (or the persistent music bar) just kept running
forever on a dead connection - both devices looked fully active, since
nothing ever told the old one to stop or navigate away. Second: the
obvious fix (call the same leave path Quitter uses) ran into a worse
bug - POST /leave removes a member by userId alone with no staleness
guard, so the old devices own leave call would have ended the NEW
devices membership too. Fixed both: onClose now tears down local
playback and leaves whenever this routes own explicit teardown has not
already run, and the leave route (plus the client, which now sends its
own deviceId) gained the same staleness guard the socket handlers
already had, via a new PartyService.isStaleDevice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:23:22 +02:00

293 lines
14 KiB
TypeScript

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 { PlaystateService } from '../src/services/PlaystateService';
import { CastTokenService } from '../src/services/CastTokenService';
import { EmbySessionService } from '../src/services/EmbySessionService';
import type { AppSocket } from '../src/types';
describe('PartyService', () => {
it('create() sets mediaType and seeds the host as the first member', () => {
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01 - Pilote', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01 - Pilote', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02 - Suite', itemImageUrl: '/img/ep2' }
], 'video');
assert.equal(party.mediaType, 'video');
assert.equal(party.members.size, 1);
assert.equal(party.hostUserId, 'u-alice');
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null, 'no text subtitle pick until the host picks one');
});
it('advanceQueue moves through the fixed queue and stops at the end', () => {
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');
assert.equal(PartyService.advanceQueue(party), true);
assert.equal(party.itemId, 'ep2');
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
});
it('advanceQueue clears the party\'s burn-in/text-subtitle choice - a new item\'s stream indices mean nothing on the old one', () => {
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');
party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2';
party.textSubtitleIndex = '3';
PartyService.advanceQueue(party);
assert.equal(party.burnInSubtitleIndex, null);
assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null);
});
it('advanceQueue marks the outgoing item Played for every connected member with a token, under their own account - see PlaystateService', (t) => {
const calls: [string, string, string][] = [];
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
calls.push([embyToken, userId, itemId]);
});
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');
PartyService.join(party, 'u-bob', 'Bob');
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
PartyService.setMemberEmbyToken(party, 'u-bob', 'tok-bob');
// A real socket connection isn't needed for anything else this test
// checks - just enough to pass advanceQueue's own "connected" guard,
// same one the periodic sweep in index.ts already uses.
party.members.get('u-alice')!.connection = {} as unknown as AppSocket;
party.members.get('u-bob')!.connection = {} as unknown as AppSocket;
PartyService.advanceQueue(party);
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 'ep1'], ['tok-bob', 'u-bob', 'ep1']].sort(), 'marks the OUTGOING item (ep1), not the new one just moved to');
});
it('advanceQueue never marks a member Played if they have no connection or no token yet', (t) => {
let called = false;
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
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');
// Host never got a connection/token set at all in this test - same as
// a REST-created party nobody has actually joined a socket to yet.
PartyService.advanceQueue(party);
assert.equal(called, false);
});
it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const result = PartyService.leave(party, 'u-bob');
assert.equal(result.destroyed, false);
assert.equal(result.hostChanged, false);
});
it('leave(): the host leaving with someone left hands off, and party.hostUserId actually updates', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const result = PartyService.leave(party, 'u-alice');
assert.equal(result.hostChanged, true);
assert.equal(result.newHostUserId, 'u-bob');
assert.equal(party.hostUserId, 'u-bob');
});
it('leave(): the last member leaving destroys the party', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const result = PartyService.leave(party, 'u-alice');
assert.equal(result.destroyed, true);
assert.equal(PartyService.getById(party.id), null);
});
it('leave() revokes whatever cast token the leaving member held for this party, even when they weren\'t the host and the party survives', (t) => {
const calls: [string, string][] = [];
t.mock.method(CastTokenService, 'revokeForMember', (partyId: string, userId: string) => { calls.push([partyId, userId]); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [[party.id, 'u-bob']]);
});
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
const calls: unknown[][] = [];
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.embyToken = 'tok-bob';
bob.positionTicks = 555;
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
});
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
let called = false;
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
PartyService.leave(party, 'u-bob');
assert.equal(called, false);
});
it('isStaleDevice(): false when the deviceId matches the member\'s own', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a';
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
});
it('isStaleDevice(): true when the deviceId differs from the member\'s own - the actual takeover case', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a'; // device B's takeover already moved this forward
assert.equal(PartyService.isStaleDevice(alice, 'device-b'), true);
});
it('isStaleDevice(): false when the member has no recorded deviceId yet - nothing to conflict with, same reasoning as the socket "join" handler', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!; // deviceId still null - no socket "join" has succeeded yet
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
});
it('isStaleDevice(): false when the caller sent no deviceId at all - only ever blocks on positive evidence of a mismatch', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a';
assert.equal(PartyService.isStaleDevice(alice, null), false);
});
it('isStaleDevice(): false for an unknown member (already left some other way)', () => {
assert.equal(PartyService.isStaleDevice(undefined, 'device-a'), false);
});
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.isPaused = false;
bob.positionTicks = 12345;
bob.deviceId = 'device-1';
// The REST "Rejoindre" route (and a second device/tab's socket "join")
// call this every time regardless of whether the caller is already a
// member - this must not reset any of the live state just set above
// (see this method's own comment for the crash that used to cause).
const bobAgain = PartyService.join(party, 'u-bob', 'Bob');
assert.equal(bobAgain, bob, 'must be the exact same member object, not a fresh one');
assert.equal(bobAgain.isPaused, false, 'a re-join must not reset live state');
assert.equal(bobAgain.positionTicks, 12345);
assert.equal(bobAgain.deviceId, 'device-1');
assert.equal(party.members.size, 2, 'still exactly one entry for bob, not a duplicate');
});
it('removeIdleParties sweeps a party whose lastActivityUtc is past the cutoff', () => {
const party = PartyService.create('u-dave', 'Dave', 'm1', 'Un Film', '/img/m1', null, 'video');
party.lastActivityUtc = Date.now() - 5 * 60 * 60 * 1000;
PartyService.removeIdleParties(4 * 60 * 60 * 1000);
assert.equal(PartyService.getById(party.id), null);
});
it('toPublicState reports youAreHost for the creator, and music:null for a video party', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.youAreHost, true);
assert.equal(state.music, null);
});
it('toPublicState exposes the party\'s current burn-in choice - a late joiner\'s getParty must see it too', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2';
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.burnInSubtitleIndex, '4');
assert.equal(state.burnInAudioIndex, '2');
});
it('toPublicState exposes the party\'s current text subtitle choice - a late joiner\'s getParty must see it too', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
party.textSubtitleIndex = '3';
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.textSubtitleIndex, '3');
});
it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
assert.equal(party.members.get('u-alice')?.embyToken, 'tok-alice');
assert.doesNotThrow(() => PartyService.setMemberEmbyToken(party, 'u-nobody', 'tok-x'));
});
it('getCurrentPlaybackState (video) reads the host\'s own tracked position, not a follower\'s', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const host = party.members.get('u-alice')!;
host.isPaused = false;
host.positionTicks = 5_000_000;
// A follower's own isPaused/positionTicks are never actually tracked
// today (see the method's own comment) - setting them here proves
// the result comes from the host, not whichever member happens to
// be iterated first.
const bob = party.members.get('u-bob')!;
bob.isPaused = true;
bob.positionTicks = 999;
const state = PartyService.getCurrentPlaybackState(party);
assert.deepEqual(state, { itemId: 'm1', positionTicks: 5_000_000, isPaused: false });
});
it('getCurrentPlaybackState (music) reads the shared queue position - the same for every member', () => {
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: 60_000_000 }, 'u-alice', 'Alice');
const state = PartyService.getCurrentPlaybackState(party);
assert.equal(state?.itemId, 't1');
assert.equal(state?.isPaused, false);
assert.equal(typeof state?.positionTicks, 'number');
});
it('getCurrentPlaybackState is null when there\'s genuinely nothing playing (an empty music queue)', () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
assert.equal(PartyService.getCurrentPlaybackState(party), null);
});
});