import { describe, it, before, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { StaticPartyService, computeMissedIds, mergeItemLists } from '../src/services/StaticPartyService'; import { StaticPartyStore } from '../src/services/StaticPartyStore'; import { PartyService } from '../src/services/PartyService'; import { PlaystateService } from '../src/services/PlaystateService'; import type { AppSocket, QueueItem } from '../src/types'; const item = (id: string): QueueItem => ({ itemId: id, itemName: `Episode ${id}`, itemImageUrl: `/img/${id}` }); const ITEMS = ['e1', 'e2', 'e3', 'e4'].map(item); const alice = { userId: 'u-alice', userName: 'Alice' }; const bob = { userId: 'u-bob', userName: 'Bob' }; const NONE: ReadonlySet = new Set(); let dataDir: string; before(() => { dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-static-')); StaticPartyStore.configure(dataDir); }); beforeEach(() => { StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test fs.rmSync(path.join(dataDir, 'static-parties.json'), { force: true }); fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { force: true }); StaticPartyService.init(); }); describe('computeMissedIds', () => { it('is every group-completed item, in the items\' own order', () => { assert.deepEqual(computeMissedIds(ITEMS, ['e2', 'e1'], [], NONE), ['e1', 'e2']); }); it('drops what the viewer watched in-app and what Emby already has as Played', () => { assert.deepEqual(computeMissedIds(ITEMS, ['e1', 'e2', 'e3'], ['e1'], new Set(['e3'])), ['e2']); }); it('never lists an item the group has not completed yet, even if the viewer watched ahead', () => { assert.deepEqual(computeMissedIds(ITEMS, ['e1'], ['e4'], NONE), ['e1']); }); }); describe('mergeItemLists', () => { it('keeps the existing order, appends new items and keeps ones gone from the source', () => { const { items, added } = mergeItemLists([item('e1'), item('e2')], [item('e2'), item('e3')]); assert.deepEqual(items.map((i) => i.itemId), ['e1', 'e2', 'e3']); assert.equal(added, 1); }); it('refreshes a known item\'s name from the fresh fetch', () => { const { items } = mergeItemLists([item('e1')], [{ ...item('e1'), itemName: 'Renamed' }]); assert.equal(items[0]!.itemName, 'Renamed'); }); }); describe('StaticPartyService', () => { it('create() makes the creator the first member, with nothing watched yet', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); assert.deepEqual(Object.keys(sp.members), ['u-alice']); assert.equal(sp.group.nextIndex, 0); assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']); }); it('a group completion advances the group and credits only connected participants', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.join(sp, bob); const link = { staticPartyId: sp.id, mode: 'group' as const }; StaticPartyService.onItemCompleted(link, 'e1', [alice, bob]); StaticPartyService.onItemCompleted(link, 'e2', [alice]); // Bob missed this one assert.equal(sp.group.nextIndex, 2); assert.deepEqual(sp.group.completedItemIds, ['e1', 'e2']); assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e2']); assert.deepEqual(StaticPartyService.missedIds(sp, alice.userId, NONE), []); assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e3', 'e4']); }); it('a late joiner sees everything the group completed before them as missed', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); StaticPartyService.join(sp, bob); assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e1']); }); it('a connected participant who never joined the static party becomes a member on completion', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice, bob]); assert.deepEqual(sp.members[bob.userId]!.watchedItemIds, ['e1']); }); it('a catch-up completion credits the participant without moving the group', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); StaticPartyService.join(sp, bob); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'catchUp' }, 'e1', [bob]); assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []); assert.equal(sp.group.nextIndex, 1, 'unchanged by a catch-up'); assert.deepEqual(sp.group.completedItemIds, ['e1']); }); it('markWatched removes an item from the missed list, and refuses an unknown item', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); assert.equal(StaticPartyService.markWatched(sp, bob, 'e1'), true); assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []); assert.equal(StaticPartyService.markWatched(sp, bob, 'nope'), false); }); it('recordPosition only keeps a position for the group\'s next item, and a completion resets it', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); const link = { staticPartyId: sp.id, mode: 'group' as const }; StaticPartyService.recordPosition(link, 'e3', 999); assert.equal(sp.group.resumePositionTicks, 0, 'not the next item - ignored'); StaticPartyService.recordPosition(link, 'e1', 123); assert.equal(sp.group.resumePositionTicks, 123); StaticPartyService.recordPosition({ ...link, mode: 'catchUp' }, 'e1', 456); assert.equal(sp.group.resumePositionTicks, 123, 'a catch-up session never moves the group'); StaticPartyService.onItemCompleted(link, 'e1', [alice]); assert.equal(sp.group.resumePositionTicks, 0); }); it('refreshItems appends new episodes without losing progress', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS.slice(0, 2)); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); StaticPartyService.refreshItems(sp, ITEMS); assert.deepEqual(sp.items.map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']); assert.equal(sp.group.nextIndex, 1); assert.deepEqual(sp.group.completedItemIds, ['e1']); }); it('only the creator can delete; leaving keeps the party', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.join(sp, bob); assert.equal(StaticPartyService.delete(sp, bob.userId), false); StaticPartyService.leave(sp, bob.userId); assert.ok(StaticPartyService.getById(sp.id)); assert.equal(StaticPartyService.delete(sp, alice.userId), true); assert.equal(StaticPartyService.getById(sp.id), null); }); it('onPartyDestroyed only clears the matching live session', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.setLiveParty(sp, 'p1'); StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'catchUp' }, 'p2'); assert.equal(sp.livePartyId, 'p1'); StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'group' }, 'p1'); assert.equal(sp.livePartyId, null); }); it('toDetail marks each item\'s group state and the viewer\'s own (in-app or Emby Played)', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); const detail = StaticPartyService.toDetail(sp, bob.userId, new Set(['e4']), null); assert.deepEqual(detail.items.map((i) => i.groupState), ['watched', 'next', 'upcoming', 'upcoming']); assert.deepEqual(detail.items.map((i) => i.viewerWatched), [false, false, false, true]); assert.deepEqual(detail.missedItemIds, ['e1']); assert.equal(detail.youAreMember, false); assert.equal(detail.missedCount, 1); }); }); describe('StaticPartyStore', () => { it('round-trips every party through the JSON file, without runtime-only state', async () => { const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); StaticPartyService.setLiveParty(sp, 'live-1'); await StaticPartyStore.writeNow(); const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'static-parties.json'), 'utf8')); assert.equal(onDisk.version, 1); assert.equal('livePartyId' in onDisk.parties[0], false); StaticPartyService.init(); const reloaded = StaticPartyService.getById(sp.id)!; assert.equal(reloaded.name, 'Soirées'); assert.deepEqual(reloaded.group.completedItemIds, ['e1']); assert.equal(reloaded.livePartyId, null, 'a restart ends every live session'); }); it('a corrupt file is moved aside and the service starts empty instead of crashing', () => { fs.writeFileSync(path.join(dataDir, 'static-parties.json'), '{ not json', 'utf8'); StaticPartyService.init(); assert.equal(StaticPartyService.list().length, 0); assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.corrupt'))); }); it('a missing file is a normal first start', () => { StaticPartyService.init(); assert.equal(StaticPartyService.list().length, 0); }); }); describe('PartyService <-> static watch party', () => { const connected = { emit() {}, disconnect() {} } as unknown as AppSocket; it('advanceQueue credits the outgoing item to a linked static party, once', (t) => { t.mock.method(PlaystateService, 'markPlayed', async () => {}); const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video'); party.staticLink = { staticPartyId: sp.id, mode: 'group' }; party.members.get(alice.userId)!.connection = connected; PartyService.completeCurrentItem(party); // "itemEnded"... PartyService.advanceQueue(party); // ...then "next" on the same item: must not double-count assert.deepEqual(sp.group.completedItemIds, ['e1']); assert.deepEqual(sp.members[alice.userId]!.watchedItemIds, ['e1']); assert.equal(sp.group.nextIndex, 1); }); it('an ordinary party never touches any static party', (t) => { t.mock.method(PlaystateService, 'markPlayed', async () => {}); const completed = t.mock.method(StaticPartyService, 'onItemCompleted'); const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video'); party.members.get(alice.userId)!.connection = connected; PartyService.advanceQueue(party); assert.equal(completed.mock.callCount(), 0); }); it('the last member leaving a group session clears the static party\'s live session', () => { const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS); const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video'); party.staticLink = { staticPartyId: sp.id, mode: 'group' }; StaticPartyService.setLiveParty(sp, party.id); PartyService.leave(party, alice.userId); assert.equal(sp.livePartyId, null); }); });