- 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>
89 lines
4.3 KiB
TypeScript
89 lines
4.3 KiB
TypeScript
// 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
|
|
);
|
|
});
|
|
});
|