The "only rewrites the party that changed" test compared a file's mtimeMs against the exact millisecond passed to utimesSync. Linux reports mtimeMs as a float that can land a hair off it (...780.999 for ...781), failing CI on master. Compare against the mtime the filesystem reports right after setting it instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
16 KiB
TypeScript
299 lines
16 KiB
TypeScript
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<string> = new Set();
|
|
|
|
let dataDir: string;
|
|
|
|
before(() => {
|
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-static-'));
|
|
StaticPartyStore.configure(dataDir);
|
|
});
|
|
|
|
const partiesDir = () => path.join(dataDir, 'static-parties');
|
|
|
|
beforeEach(() => {
|
|
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
|
|
fs.rmSync(partiesDir(), { recursive: true, force: true });
|
|
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, 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('writes one file per party, and round-trips it without runtime-only state', async () => {
|
|
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
|
|
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
|
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
|
StaticPartyService.setLiveParty(sp, 'live-1');
|
|
await StaticPartyStore.writeNow();
|
|
|
|
assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
|
|
assert.equal(onDisk.version, 1);
|
|
assert.equal('livePartyId' in onDisk.party, 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');
|
|
assert.ok(StaticPartyService.getById(other.id));
|
|
});
|
|
|
|
it('a change only rewrites the party that changed', async () => {
|
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
|
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
|
|
await StaticPartyStore.writeNow();
|
|
const otherFile = path.join(partiesDir(), `${other.id}.json`);
|
|
const past = new Date(Date.now() - 60000);
|
|
fs.utimesSync(otherFile, past, past);
|
|
// Compared against what the filesystem itself reports back, not
|
|
// past.getTime(): Linux returns mtimeMs as a float that can land a
|
|
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
|
|
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
|
|
|
|
StaticPartyService.markWatched(sp, alice, 'e1');
|
|
await StaticPartyStore.writeNow();
|
|
|
|
assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
|
|
});
|
|
|
|
it('deleting a party removes its file, even with a save still pending', async () => {
|
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
|
await StaticPartyStore.writeNow();
|
|
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
|
|
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
|
|
await StaticPartyStore.writeNow();
|
|
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
|
|
});
|
|
|
|
it('a corrupt file is moved aside and only that party is lost', async () => {
|
|
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
|
|
await StaticPartyStore.writeNow();
|
|
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
|
|
|
|
StaticPartyService.init();
|
|
|
|
assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
|
|
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
|
|
});
|
|
|
|
it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
|
|
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
|
|
await StaticPartyStore.writeNow();
|
|
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
|
|
|
|
StaticPartyService.init();
|
|
|
|
assert.ok(StaticPartyService.getById(sp.id));
|
|
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
|
|
});
|
|
|
|
it('migrates the first release\'s single static-parties.json into one file per party', () => {
|
|
const legacy = { version: 1, parties: [
|
|
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
|
|
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
|
|
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
|
|
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
|
|
] };
|
|
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
|
|
|
|
StaticPartyService.init();
|
|
|
|
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
|
|
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
|
|
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
|
|
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
|
|
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
|
|
});
|
|
|
|
it('an empty data directory 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);
|
|
});
|
|
});
|