- 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>
184 lines
8.1 KiB
TypeScript
184 lines
8.1 KiB
TypeScript
// 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');
|
|
});
|
|
});
|