// PlaystateService.updatePosition/markPlayed - mocks EmbyClient.fetch // (not the global fetch it wraps), same isolation style as // catalogService.test.ts, since this is about what PlaystateService // itself sends/merges, 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'; /** What a real `GET /Users/{userId}/Items/{itemId}` comes back with - * confirmed directly against a real server that the requesting user's * own UserData is always embedded right on the item like this. */ function itemResponse(userData: Record | undefined): Response { return new Response(JSON.stringify({ Id: 'item1', UserData: userData }), { status: 200 }); } describe('PlaystateService.updatePosition', () => { it('GETs the item, then POSTs UserData with only PlaybackPositionTicks changed - everything else survives untouched', async (t) => { const capturedCalls: { path: string; token: string; options: unknown }[] = []; t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => { capturedCalls.push({ path, token, options }); if (capturedCalls.length === 1) { return itemResponse({ PlaybackPositionTicks: 0, Played: false, IsFavorite: true, Rating: 9.5, PlayCount: 3 }); } return new Response('{}', { status: 200 }); }); await PlaystateService.updatePosition('tok-alice', 'u-alice', 'item1', 12_345_678); assert.equal(capturedCalls.length, 2); assert.equal(capturedCalls[0]!.path, '/Users/u-alice/Items/item1'); assert.equal(capturedCalls[0]!.token, 'tok-alice'); assert.equal(capturedCalls[0]!.options, undefined, 'the GET is a plain fetch, no method/body'); assert.equal(capturedCalls[1]!.path, '/Users/u-alice/Items/item1/UserData'); assert.equal(capturedCalls[1]!.token, 'tok-alice'); const posted = capturedCalls[1]!.options as { method: string; body: Record }; assert.equal(posted.method, 'POST'); // The whole point of fetch-merge-write: a favorite/rating/play count // this app never asked to change must come back out exactly as read - // guessing wrong here risks actually erasing real user data, not just // silently no-op'ing the way the old Progress-endpoint bugs did. assert.equal(posted.body.IsFavorite, true); assert.equal(posted.body.Rating, 9.5); assert.equal(posted.body.PlayCount, 3); assert.equal(posted.body.Played, false, 'Played is preserved, not touched by a position-only update'); assert.equal(posted.body.PlaybackPositionTicks, 12345678, 'only this field actually changes'); }); it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => { let posted: Record | undefined; t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record }) => { if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); } return itemResponse({}); }); await PlaystateService.updatePosition('tok', 'u1', 'item1', 100.7); assert.equal(posted?.PlaybackPositionTicks, 101); }); it('has no existing UserData at all (a never-touched item) - still works, starting from an empty object', async (t) => { let posted: Record | undefined; t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record }) => { if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); } return itemResponse(undefined); }); await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 5000)); assert.equal(posted?.PlaybackPositionTicks, 5000); }); it('skips the write entirely if the GET fails - never posts with missing/corrupt data', async (t) => { let postCalled = false; t.mock.method(EmbyClient, 'fetch', async (path: string) => { if (path.endsWith('/UserData')) postCalled = true; return new Response('nope', { status: 404 }); }); await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 0)); assert.equal(postCalled, false, 'a failed read must not be followed by a write built on nothing'); }); it('swallows a thrown error instead of rejecting - 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.updatePosition('tok', 'u1', 'item1', 0)); }); }); describe('PlaystateService.markPlayed', () => { it('POSTs Played: true and resets PlaybackPositionTicks to 0, preserving other fields', async (t) => { let posted: Record | undefined; t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record }) => { if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); } return itemResponse({ PlaybackPositionTicks: 3_000_000, Played: false, IsFavorite: true }); }); await PlaystateService.markPlayed('tok', 'u1', 'item1'); assert.equal(posted?.Played, true); assert.equal(posted?.PlaybackPositionTicks, 0); assert.equal(posted?.IsFavorite, true, 'a favorite must survive being marked played'); }); it('swallows a thrown error instead of rejecting - this runs from an item-advance path that must never stall on it', async (t) => { t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); await assert.doesNotReject(() => PlaystateService.markPlayed('tok', 'u1', 'item1')); }); }); describe('PlaystateService logging', () => { it('logs both outgoing requests and the final outcome, 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 (path: string) => { if (path.endsWith('/UserData')) return new Response('{}', { status: 200 }); return itemResponse({}); }); await PlaystateService.updatePosition('super-secret-token', 'u-alice', 'item1', 5_000_000); const lines = logged.filter((l) => l.includes('[watchparty:playstate]')); assert.ok(lines.some((l) => l.includes('GET') && l.includes('/Users/u-alice/Items/item1')), 'the read must be logged'); assert.ok(lines.some((l) => l.includes('POST') && l.includes('/Users/u-alice/Items/item1/UserData')), 'the write must be logged'); assert.ok(lines.some((l) => l.includes('200') && l.includes('ok')), 'the outcome must be logged'); assert.ok(!lines.some((l) => l.includes('super-secret-token')), 'the token travels as a header, never logged'); }); it('logs a non-ok write as FAILED, 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 (path: string) => { if (path.endsWith('/UserData')) return new Response('bad request', { status: 400 }); return itemResponse({}); }); await PlaystateService.updatePosition('tok', 'u1', 'item1', 0); const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400')); assert.ok(outcomeLine); assert.ok(outcomeLine!.includes('FAILED')); }); it('logs a failed read distinctly, without ever attempting/logging the write', async (t) => { const logged: string[] = []; t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(EmbyClient, 'fetch', async () => new Response('not found', { status: 404 })); await PlaystateService.updatePosition('tok', 'u1', 'item1', 0); const lines = logged.filter((l) => l.includes('[watchparty:playstate]')); assert.ok(lines.some((l) => l.includes('404') && l.includes('FAILED'))); assert.ok(!lines.some((l) => l.includes('POST')), 'no write should even be attempted, let alone logged, once the read failed'); }); });