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>
This commit is contained in:
parent
4a31ee741e
commit
fc678b313c
9 changed files with 567 additions and 18 deletions
|
|
@ -17,8 +17,13 @@
|
||||||
# read:package (Forgejo -> Settings -> Applications), le jeton
|
# read:package (Forgejo -> Settings -> Applications), le jeton
|
||||||
# automatique de l'Action n'a pas ce droit.
|
# 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
|
# Deux jobs :
|
||||||
# (contrairement à mediaDrop) - à ajouter ici le jour où il y en a une.
|
# - 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
|
name: Publier l'image Docker
|
||||||
|
|
||||||
|
|
@ -37,7 +42,49 @@ env:
|
||||||
IMAGE: git.dev.kyuno.fr/${{ github.repository_owner }}/watchparty
|
IMAGE: git.dev.kyuno.fr/${{ github.repository_owner }}/watchparty
|
||||||
|
|
||||||
jobs:
|
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:
|
publish:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: docker:27-cli
|
image: docker:27-cli
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -59,15 +59,20 @@ albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
|
||||||
(video or audio) are still unverified.
|
(video or audio) are still unverified.
|
||||||
|
|
||||||
The TypeScript rewrite itself is separately verified: both projects
|
The TypeScript rewrite itself is separately verified: both projects
|
||||||
type-check in strict mode, the compiled server passes the same
|
type-check in strict mode, the server has a real automated test suite
|
||||||
unit/integration tests the pre-rewrite JS did (run against the compiled
|
(`server/test/*.test.ts`, Node's built-in test runner, see Running
|
||||||
output, not the source), and the compiled+bundled client was driven
|
locally below) covering the party lifecycle, the music queue's
|
||||||
through every page and both players end to end (mocked network/Socket.IO)
|
timer/vote/pause behavior, the socket protocol, and the catalog/streaming
|
||||||
in a real browser. **Not verified**: an actual `docker build` of the new
|
overrides, and the compiled+bundled client was driven through every page
|
||||||
multi-stage Dockerfile - this dev environment has no Docker - so treat the
|
and both players end to end (mocked network/Socket.IO) in a real browser.
|
||||||
first real build as the thing to double-check, even though it mirrors
|
CI (Forgejo Actions) runs that same typecheck+test+build sequence on
|
||||||
exactly the `npm run build` commands already confirmed working locally
|
every push and gates the Docker image publish on it passing — see
|
||||||
for both projects.
|
[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
|
## 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
|
`tsx` (no separate compile step needed while iterating) - run both in
|
||||||
separate terminals.
|
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
|
## Deploying
|
||||||
|
|
||||||
Build the Docker image from the **repo root** (it needs both `server/` and
|
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
|
- [x] Full TypeScript rewrite (client + server), component-style client
|
||||||
views (.ts/.html/.css each), server-side singleton services per
|
views (.ts/.html/.css each), server-side singleton services per
|
||||||
feature — see docs/architecture.md
|
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)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Tune transcoding target / support direct play when possible
|
- [ ] Tune transcoding target / support direct play when possible
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
transpiles without checking; the client's own separate `tsc --noEmit`
|
||||||
step is what actually guards against that gap.
|
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
|
(party lifecycle logging, the music queue's timer/vote/pause behavior
|
||||||
including real `setTimeout`-based auto-advance, the socket-level
|
including real `setTimeout`-based auto-advance, the socket-level
|
||||||
integration through `SocketGateway`+`PartyService`+`MusicQueueService`
|
integration through `SocketGateway`+`PartyService`+`MusicQueueService`
|
||||||
together, the library-type filter, the playback-override-forcing logic)
|
together, the library-type filter, the playback-override-forcing logic)
|
||||||
was re-run against the **compiled** `server/dist` output, not the
|
was first re-run ad hoc against the **compiled** `server/dist` output to
|
||||||
TypeScript source - every one passed unchanged (same assertions, same
|
confirm the port itself changed nothing behaviorally, then formalized
|
||||||
counts). The compiled server was also actually started (`node dist/index.js`)
|
into a real, committed suite: `server/test/*.test.ts` (one file per
|
||||||
and confirmed listening cleanly, both directly and via `tsx` for the dev
|
service - `musicQueueService`, `partyService`, `socketGateway`,
|
||||||
path.
|
`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
|
Client: since esbuild bundles everything into one `app.js` (no more
|
||||||
individually-importable view modules the way separate `<script
|
individually-importable view modules the way separate `<script
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc -p tsconfig.test.json",
|
||||||
|
"test": "tsx --test test/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
89
server/test/catalogService.test.ts
Normal file
89
server/test/catalogService.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
113
server/test/musicQueueService.test.ts
Normal file
113
server/test/musicQueueService.test.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
70
server/test/partyService.test.ts
Normal file
70
server/test/partyService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
server/test/socketGateway.test.ts
Normal file
184
server/test/socketGateway.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
8
server/tsconfig.test.json
Normal file
8
server/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue