Files
Psychotoxical-psysonic/src/utils/orbit/helpers.test.ts
T
Psychotoxical a4fbd45d5d fix(orbit): host-side session guards — settings default, maxUsers, push serialisation (#1157)
* fix(orbit): unify session-settings default to the canonical preset

updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.

* fix(orbit): enforce maxUsers host-side in the participant rebuild

The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.

* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes

pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.

Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.

* docs(changelog): add Orbit host-side session guards (1157)
2026-06-22 15:25:39 +02:00

106 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
ORBIT_STATE_MAX_BYTES,
makeInitialOrbitState,
parseOrbitState,
type OrbitQueueItem,
type OrbitState,
} from '../../api/orbit';
import {
makeCoalescedRunner,
OrbitStateTooLarge,
serialiseOrbitState,
serialiseOrbitStateForWire,
} from './helpers';
function baseState(): OrbitState {
return makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
}
function makeQueue(n: number): OrbitQueueItem[] {
// addedAt == index, so "oldest" is the lowest addedAt.
return Array.from({ length: n }, (_, i) => ({
trackId: `track-${i}-${'x'.repeat(40)}`,
addedBy: `user${i % 10}`,
addedAt: i,
}));
}
function byteLen(s: string): number {
return new TextEncoder().encode(s).length;
}
describe('serialiseOrbitStateForWire', () => {
it('passes a within-budget state through untouched', () => {
const state = { ...baseState(), queue: makeQueue(5) };
expect(serialiseOrbitStateForWire(state)).toBe(serialiseOrbitState(state));
});
it('trims oldest suggestions until the blob fits the byte budget', () => {
const state = { ...baseState(), queue: makeQueue(300) };
// Sanity: the untrimmed state really is over budget.
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed).not.toBeNull();
const retained = parsed!.queue;
expect(retained.length).toBeGreaterThan(0);
expect(retained.length).toBeLessThan(300);
// The dropped entries are the oldest — every retained addedAt is a
// contiguous suffix ending at the newest (299).
const addedAts = retained.map(q => q.addedAt);
expect(Math.max(...addedAts)).toBe(299);
expect(Math.min(...addedAts)).toBe(300 - retained.length);
});
it('falls back to trimming the play queue once history is exhausted', () => {
const playQueue = Array.from({ length: 400 }, (_, i) => ({
trackId: `pq-${i}-${'y'.repeat(40)}`,
addedBy: `user${i % 10}`,
}));
const state: OrbitState = { ...baseState(), queue: [], playQueue, playQueueTotal: playQueue.length };
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed!.queue).toEqual([]);
expect((parsed!.playQueue ?? []).length).toBeLessThan(400);
});
});
describe('makeCoalescedRunner', () => {
// Let the microtask queue drain so an awaiting do-while loop can advance.
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
it('never runs two task bodies concurrently and coalesces mid-flight calls into one rerun', async () => {
let starts = 0;
const releases: Array<() => void> = [];
const task = () => {
starts++;
return new Promise<void>(res => { releases.push(res); });
};
const run = makeCoalescedRunner(task);
void run(); // run #1 starts and blocks
void run(); // in-flight → flags a rerun, no second body
void run(); // in-flight → still just one pending rerun
expect(starts).toBe(1);
releases[0](); // finish #1 → the coalesced rerun fires exactly one more body
await flush();
expect(starts).toBe(2);
releases[1](); // finish #2 → no rerun pending, runner goes idle
await flush();
expect(starts).toBe(2);
void run(); // lock released → a fresh call starts a new body
expect(starts).toBe(3);
});
});