watchparty/server/test/musicQueueService.test.ts
Nicolas fc678b313c
Some checks failed
Publier l'image Docker / test (push) Failing after 11s
Publier l'image Docker / publish (push) Has been skipped
Add a real server test suite and gate CI's Docker publish on it
- server/test/*.test.ts: formalizes this project's many ad-hoc scratchpad
  test scripts into a committed suite, one file per service
  (musicQueueService, partyService, socketGateway, catalogService), using
  Node's built-in node:test + node:assert/strict - zero new dependencies.
  Runs straight against TypeScript source via `tsx --test` (npm test), no
  compile step.
- server/tsconfig.test.json: extends the base config, overrides rootDir to
  "." and adds test/**/*.ts to include, so `npm run typecheck` covers the
  test files too without pulling them into dist/ (npm run build still
  uses the unmodified base tsconfig, scoped to src/ only).
- server/package.json: typecheck now runs against tsconfig.test.json; new
  test script.
- .forgejo/workflows/publish-docker.yml: new `test` job (node:20-alpine
  container - the publish job's docker:27-cli has no Node) running server
  typecheck+test and client typecheck+build; publish now depends on it via
  needs: test. Removed the stale "no test suite yet" comment.
- README.md, docs/architecture.md: documented the test suite, how to run
  it locally, and the new CI job/gating.

Verified locally: server typecheck, server test (25/25 passing), server
build, client typecheck, and client build all pass individually - the
same commands the new CI job runs.

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

113 lines
5.4 KiB
TypeScript

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