watchparty/server/test/playstateService.test.ts
Nicolas e6d71f2e37
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 14s
Log the playstate sweep's own requests, not just its failures
Follow-up to the last fix (wrong Progress param shape): now that the
request shape is corrected, the obvious next question is "is it
actually being sent right, and is Emby accepting it" - answered from
the logs alone, without cross-referencing anything else.

PlaystateService.reportProgress now logs the exact outgoing request
right before sending it (method, path, query string - everything
actually sent) and its outcome right after (the real HTTP status and
whether it was ok, or that the call threw), all under
[watchparty:playstate]. EmbyClient's own generic [watchparty:emby]
failure log still fires too, independently, with the fuller error
body - this is a second, feature-scoped log so `docker logs | grep
watchparty:playstate` alone shows the whole story for this specific
sweep.

Deliberately safe to log the full path: the query string here is only
ever MediaSourceId/PositionTicks/IsPaused, never a token - the real
embyToken travels as a header, not the URL - unlike Logger.stream's
own request logging, which stays partial specifically because Emby's
own TranscodingUrl can carry an api_key.

Tests: request logging (path/params present, token absent), and
outcome logging for a success, a non-ok status, and a thrown error
each producing a distinct line. Full typecheck/test/build green
(96/96 tests). docs/architecture.md updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 22:42:49 +02:00

111 lines
5.7 KiB
TypeScript

// 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', 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.
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('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', 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', 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', 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('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', 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 the original wrong request shape would have hit', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response('bad request', { status: 400 }));
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 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', 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'));
});
});