Add a real server test suite and gate CI's Docker publish on it
Some checks failed
Publier l'image Docker / test (push) Failing after 11s
Publier l'image Docker / publish (push) Has been skipped

- 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>
This commit is contained in:
Nicolas 2026-09-14 09:57:47 +02:00
parent 4a31ee741e
commit fc678b313c
9 changed files with 567 additions and 18 deletions

View file

@ -17,8 +17,13 @@
# read:package (Forgejo -> Settings -> Applications), le jeton
# automatique de l'Action n'a pas ce droit.
#
# Pas de job de test : ce projet n'a pas de suite de tests pour l'instant
# (contrairement à mediaDrop) - à ajouter ici le jour où il y en a une.
# Deux jobs :
# - test : typecheck + tests serveur (node:test, voir server/test/) et
# typecheck + build client, dans un conteneur node:20-alpine
# (docker:27-cli n'a pas Node, donc un conteneur séparé).
# - publish : build + push l'image Docker, seulement si `test` passe
# (needs: test) - clone les sources une seconde fois dans son
# propre conteneur, les deux jobs ne partagent pas de filesystem.
name: Publier l'image Docker
@ -37,7 +42,49 @@ env:
IMAGE: git.dev.kyuno.fr/${{ github.repository_owner }}/watchparty
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20-alpine
steps:
# node:20-alpine n'a pas git préinstallé - même approche de clone
# manuel que le job publish (pas d'actions/checkout, action JS qui a
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
- name: Récupérer les sources
run: |
set -eu
apk add --no-cache git
git init -q
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.ref_name }}"
git checkout -q FETCH_HEAD
- name: Serveur - installer les dépendances
working-directory: server
run: npm ci
- name: Serveur - vérifier les types (src/ + test/)
working-directory: server
run: npm run typecheck
- name: Serveur - lancer la suite de tests
working-directory: server
run: npm test
- name: Client - installer les dépendances
working-directory: client
run: npm ci
- name: Client - vérifier les types
working-directory: client
run: npm run typecheck
- name: Client - build (esbuild)
working-directory: client
run: npm run build
publish:
needs: test
runs-on: ubuntu-latest
container:
image: docker:27-cli

View file

@ -59,15 +59,20 @@ albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
(video or audio) are still unverified.
The TypeScript rewrite itself is separately verified: both projects
type-check in strict mode, the compiled server passes the same
unit/integration tests the pre-rewrite JS did (run against the compiled
output, not the source), and the compiled+bundled client was driven
through every page and both players end to end (mocked network/Socket.IO)
in a real browser. **Not verified**: an actual `docker build` of the new
multi-stage Dockerfile - this dev environment has no Docker - so treat the
first real build as the thing to double-check, even though it mirrors
exactly the `npm run build` commands already confirmed working locally
for both projects.
type-check in strict mode, the server has a real automated test suite
(`server/test/*.test.ts`, Node's built-in test runner, see Running
locally below) covering the party lifecycle, the music queue's
timer/vote/pause behavior, the socket protocol, and the catalog/streaming
overrides, and the compiled+bundled client was driven through every page
and both players end to end (mocked network/Socket.IO) in a real browser.
CI (Forgejo Actions) runs that same typecheck+test+build sequence on
every push and gates the Docker image publish on it passing — see
[docs/architecture.md](docs/architecture.md#continuous-integration).
**Not verified**: an actual `docker build` of the new multi-stage
Dockerfile - this dev environment has no Docker - so treat the first real
build as the thing to double-check, even though it mirrors exactly the
`npm run build` commands already confirmed working locally for both
projects.
## Project layout
@ -150,6 +155,16 @@ For active development, `client`'s `npm run watch` rebuilds on save, and
`tsx` (no separate compile step needed while iterating) - run both in
separate terminals.
Server tests (`server/test/*.test.ts`, Node's built-in test runner, no
extra dependencies or compile step):
```bash
cd server && npm test
```
`server`'s `npm run typecheck` also covers `server/test/`, not just `src/`
(see `tsconfig.test.json`).
## Deploying
Build the Docker image from the **repo root** (it needs both `server/` and
@ -184,6 +199,8 @@ Then put it behind your existing reverse proxy like everything else
- [x] Full TypeScript rewrite (client + server), component-style client
views (.ts/.html/.css each), server-side singleton services per
feature — see docs/architecture.md
- [x] Automated server test suite (`node:test`) + CI job gating the Docker
publish on typecheck+test+build passing — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible

View file

@ -1057,16 +1057,36 @@ which `esbuild` would do even with type errors present, since it
transpiles without checking; the client's own separate `tsc --noEmit`
step is what actually guards against that gap.
Server: every unit/integration test written earlier for the JS version
Server: every unit/integration check written earlier for the JS version
(party lifecycle logging, the music queue's timer/vote/pause behavior
including real `setTimeout`-based auto-advance, the socket-level
integration through `SocketGateway`+`PartyService`+`MusicQueueService`
together, the library-type filter, the playback-override-forcing logic)
was re-run against the **compiled** `server/dist` output, not the
TypeScript source - every one passed unchanged (same assertions, same
counts). The compiled server was also actually started (`node dist/index.js`)
and confirmed listening cleanly, both directly and via `tsx` for the dev
path.
was first re-run ad hoc against the **compiled** `server/dist` output to
confirm the port itself changed nothing behaviorally, then formalized
into a real, committed suite: `server/test/*.test.ts` (one file per
service - `musicQueueService`, `partyService`, `socketGateway`,
`catalogService`), using Node's built-in `node:test` + `node:assert/strict`
(zero new dependencies) and run straight against the TypeScript source via
`tsx --test` (`npm test`) - no compile step, no separate test-runner
dependency. A dedicated `tsconfig.test.json` (extends the base config,
overrides `rootDir` to `.` and adds `test/**/*.ts` to `include`) lets
`npm run typecheck` cover the test files too without pulling them into
`dist/` when `npm run build` (plain `tsc`, still scoped to `src/`) runs.
The compiled server was also actually started (`node dist/index.js`) and
confirmed listening cleanly, both directly and via `tsx` for the dev path.
### Continuous integration
`.forgejo/workflows/publish-docker.yml` gained a `test` job that runs
before `publish` (`needs: test`): server `npm run typecheck` + `npm test`,
then client `npm run typecheck` + `npm run build`. It runs in a
`node:20-alpine` container - a separate container from `publish`'s
`docker:27-cli`, since that one has no Node at all (that's also why both
jobs clone sources by hand with `git init`/`fetch`/`checkout` rather than
`actions/checkout`, which is a JS action and needs Node to run). The two
jobs don't share a filesystem, so each clones independently; `publish`
only starts once `test` succeeds.
Client: since esbuild bundles everything into one `app.js` (no more
individually-importable view modules the way separate `<script

View file

@ -8,7 +8,8 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc -p tsconfig.test.json",
"test": "tsx --test test/**/*.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -0,0 +1,89 @@
// Exercises CatalogService's real logic (library-type filtering, the
// audio/subtitle/quality override forcing) through its PUBLIC methods
// against a mocked EmbyClient, not by calling private helpers directly.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbySession } from '../src/types';
const session: EmbySession = { embyToken: 'tok', userId: 'u1', userName: 'Alice', serverId: 'srv1', createdUtc: Date.now() };
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) as unknown as Response;
}
function fakeReq(query: Record<string, string>): Request {
return { query, headers: {} } as unknown as Request;
}
function fakeRes(): Response {
return { status() { return this; }, json() {}, end() {} } as unknown as Response;
}
describe('CatalogService', () => {
it('getLibraries keeps only movies/tvshows/music/musicvideos out of everything /Views can return', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Items: [
{ Id: '1', Name: 'Films', CollectionType: 'movies' },
{ Id: '2', Name: 'Series', CollectionType: 'tvshows' },
{ Id: '3', Name: 'Musique', CollectionType: 'music' },
{ Id: '4', Name: 'Clips', CollectionType: 'musicvideos' },
{ Id: '5', Name: 'Photos', CollectionType: 'photos' },
{ Id: '6', Name: 'Live TV', CollectionType: 'livetv' },
{ Id: '7', Name: 'Collections', CollectionType: 'boxsets' },
{ Id: '8', Name: 'No type at all' }
]
}));
const libraries = await CatalogService.getLibraries(session);
assert.deepEqual(libraries.map((l) => l.name).sort(), ['Clips', 'Films', 'Musique', 'Series']);
});
it('proxyStream forces a subtitle pick (and SubtitleMethod=Encode) onto a negotiated TranscodingUrl', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1', TranscodingUrl: '/videos/item1/master.m3u8?MediaSourceId=src1&AudioStreamIndex=1' }],
PlaySessionId: 'sess1'
}));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({ SubtitleStreamIndex: '3', AudioStreamIndex: '2' }), fakeRes(), session, 'item1');
assert.ok(capturedTarget.includes('SubtitleStreamIndex=3'));
assert.ok(capturedTarget.includes('SubtitleMethod=Encode'));
assert.ok(capturedTarget.includes('AudioStreamIndex=2'));
});
it('proxyStream\'s hand-built fallback picks /Audio/ (and drops VideoCodec) for a music track', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src2' }], PlaySessionId: 'sess2' }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({ mediaType: 'audio' }), fakeRes(), session, 'track1');
assert.ok(capturedTarget.startsWith('/Audio/track1/master.m3u8'));
assert.ok(!capturedTarget.includes('VideoCodec'));
});
it('proxyStream\'s hand-built fallback picks /Videos/ (with VideoCodec) for anything else', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' }));
let capturedTarget = '';
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, _res: Response, target: string) => { capturedTarget = target; });
await CatalogService.proxyStream(fakeReq({}), fakeRes(), session, 'movie1');
assert.ok(capturedTarget.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(capturedTarget.includes('VideoCodec=h264'));
});
it('getRawItem preserves Emby\'s own status (a real 404), not a blanket 502', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 404 }) as unknown as Response);
await assert.rejects(
() => CatalogService.getRawItem(session, 'missing'),
(err: Error & { status?: number }) => err.status === 404
);
});
});

View file

@ -0,0 +1,113 @@
// 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'));
});
});

View file

@ -0,0 +1,70 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PartyService } from '../src/services/PartyService';
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');
});
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('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('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);
});
});

View file

@ -0,0 +1,184 @@
// 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 } 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');
});
});

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}