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('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); }); });