// Exercises MusicQueueService directly against fake parties - no Express/ // session/Emby involved. Covers the intricate bits: auto-start on first // add, server-authoritative auto-advance via a real timer, pause/resume // elapsed-time math, majority vote-skip, and - given how strongly memory // leaks have mattered throughout this project (see docs/architecture.md) - // that the advance timer is ALWAYS cleared (never left dangling) on skip, // on destroy, and is never double-scheduled. import { describe, it, mock } from 'node:test'; import assert from 'node:assert/strict'; import { setTimeout as sleep } from 'node:timers/promises'; import { MusicQueueService } from '../src/services/MusicQueueService'; import type { MusicState, Party } from '../src/types'; function fakeParty(id: string): Party { return { id, mediaType: 'music', itemId: '', itemName: '', itemImageUrl: '', queue: [], queueIndex: 0, hostUserId: 'u-alice', createdUtc: Date.now(), lastActivityUtc: Date.now(), members: new Map(), burnInSubtitleIndex: null, burnInAudioIndex: null }; } function music(party: Party): MusicState { return party.music as MusicState; } describe('MusicQueueService', () => { it('init() auto-starts the first track and mirrors it onto party.itemName', () => { const party = fakeParty('p1'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); assert.equal(music(party).current?.track.itemId, 't1'); assert.equal(music(party).queue.length, 0); assert.equal(party.itemName, 'Track One'); }); it('adding a track while one plays queues it instead of jumping the gun', () => { const party = fakeParty('p2'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob'); assert.equal(music(party).current?.track.itemId, 't1'); assert.equal(music(party).queue.length, 1); }); it('voteSkip needs a strict majority of connected members, not any count', () => { const party = fakeParty('p3'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob'); assert.equal(MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2), false, 'one of two needed should not skip yet'); assert.equal(music(party).current?.track.itemId, 't1'); assert.equal(MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2), true, 'the second vote should reach the majority'); assert.equal(music(party).current?.track.itemId, 't2'); assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track'); }); it('pause freezes elapsed time; resume continues it from where it froze', async () => { const party = fakeParty('p4'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice'); MusicQueueService.setPaused(party, true); assert.equal(music(party).current?.isPaused, true); const frozen = MusicQueueService.getElapsedSeconds(party); await sleep(50); assert.equal(MusicQueueService.getElapsedSeconds(party), frozen, 'time must not advance while paused'); MusicQueueService.setPaused(party, false); assert.equal(music(party).current?.isPaused, false); await sleep(50); assert.ok(MusicQueueService.getElapsedSeconds(party) >= frozen, 'time should advance again after resuming'); MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer }); it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => { const party = fakeParty('p5'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice'); assert.notEqual(music(party).advanceTimer, null, 'a timer should be scheduled for the auto-advance'); await sleep(1300); // a bit over the 1s duration assert.equal(music(party).current, null, 'nothing queued behind it, so current should now be empty'); }); it('destroy() clears a pending timer - the actual leak-prevention path', (t) => { const realClearTimeout = global.clearTimeout; const cleared = mock.fn(realClearTimeout); global.clearTimeout = cleared as typeof clearTimeout; t.after(() => { global.clearTimeout = realClearTimeout; }); const party = fakeParty('p6'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice'); assert.notEqual(music(party).advanceTimer, null); MusicQueueService.destroy(party); assert.equal(cleared.mock.callCount(), 1, 'clearTimeout should have been called exactly once'); assert.equal(music(party).advanceTimer, null); }); it('removeVote() is a harmless no-op for a party with no music state at all', () => { const videoParty: Party = { ...fakeParty('p7'), mediaType: 'video' }; delete videoParty.music; assert.doesNotThrow(() => MusicQueueService.removeVote(videoParty, 'u-anyone')); }); });