// PlaystateService.reportProgress - mocks EmbyClient.fetch (not the // global fetch it wraps), same isolation style as catalogService.test.ts, // since this is about what PlaystateService itself sends, not // EmbyClient's own plumbing (see embyClient.test.ts for that). import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { PlaystateService } from '../src/services/PlaystateService'; import { EmbyClient } from '../src/emby/EmbyClient'; describe('PlaystateService.reportProgress', () => { it('POSTs to this user\'s own PlayingItems/Progress with their own token, params in the query string not a body', async (t) => { let capturedPath = ''; let capturedToken = ''; let capturedOptions: unknown; t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => { capturedPath = path; capturedToken = token; capturedOptions = options; return new Response('{}', { status: 200 }); }); await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false); // Query params, not a JSON body - confirmed against Emby's real // OpenAPI spec (see PlaystateService.ts's own comment): the // original body-based guess was missing a required MediaSourceId // entirely and would have failed outright. PlaySessionId came next // (round 2 - see the same comment): optional per the spec, but a // real request without one still hit Emby's own "Value cannot be // null (Parameter 'key')". const [path, query] = capturedPath.split('?'); assert.equal(path, '/Users/u-alice/PlayingItems/item1/Progress'); assert.equal(new URLSearchParams(query).get('MediaSourceId'), 'src1'); assert.equal(new URLSearchParams(query).get('PlaySessionId'), 'sess1'); assert.equal(new URLSearchParams(query).get('PositionTicks'), '12345678'); assert.equal(new URLSearchParams(query).get('IsPaused'), 'false'); assert.equal(capturedToken, 'tok-alice'); assert.deepEqual(capturedOptions, { method: 'POST' }, 'no body at all - the real body schema (Api.OnPlaybackProgress) has none of these fields'); }); it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => { let capturedPath = ''; t.mock.method(EmbyClient, 'fetch', async (path: string) => { capturedPath = path; return new Response('{}', { status: 200 }); }); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 100.7, true); const query = capturedPath.split('?')[1]; assert.equal(new URLSearchParams(query).get('PositionTicks'), '101'); }); it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => { t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false)); }); // Logging: the whole point is being able to answer "what did this // actually send, and did Emby accept it" from `docker logs` alone - // see Logger.playstate's own comment. it('logs the exact outgoing request, never the token itself', async (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(EmbyClient, 'fetch', async () => new Response('{}', { status: 200 })); await PlaystateService.reportProgress('super-secret-token', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false); const requestLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('POST')); assert.ok(requestLine, 'the request itself must be logged'); assert.ok(requestLine!.includes('/Users/u-alice/PlayingItems/item1/Progress')); assert.ok(requestLine!.includes('MediaSourceId=src1')); assert.ok(requestLine!.includes('PlaySessionId=sess1')); assert.ok(requestLine!.includes('PositionTicks=12345678')); assert.ok(requestLine!.includes('IsPaused=false')); assert.ok(!requestLine!.includes('super-secret-token'), 'the token travels as a header, never logged into the request line'); }); it('logs a successful response as ok, with its real HTTP status', async (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 204 })); // 204 is a null-body status - a non-null body here would throw await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false); const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('204')); assert.ok(outcomeLine, 'the real status code must be logged'); assert.ok(outcomeLine!.includes('ok')); assert.ok(!outcomeLine!.includes('FAILED')); }); it('logs a non-ok response as FAILED, with its real HTTP status - the exact case both wrong request shapes hit for real', async (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(EmbyClient, 'fetch', async () => new Response("Value cannot be null. (Parameter 'key')", { status: 400 })); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false); const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400')); assert.ok(outcomeLine, 'the real status code must be logged'); assert.ok(outcomeLine!.includes('FAILED')); }); it('logs a thrown error (Emby unreachable) distinctly from a non-ok HTTP response', async (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false); const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('threw')); assert.ok(outcomeLine, 'a thrown error must still be logged, not just swallowed silently'); assert.ok(outcomeLine!.includes('Emby unreachable')); }); });