mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
a4fbd45d5d
* 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)
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../store/orbitStore', () => ({
|
|
useOrbitStore: { getState: () => ({ state: null, setState: vi.fn() }) },
|
|
}));
|
|
|
|
import { makeInitialOrbitState, type OrbitQueueItem, type OrbitState } from '../../api/orbit';
|
|
import { ORBIT_QUEUE_HISTORY_LIMIT } from './constants';
|
|
import { applyOutboxSnapshotsToState, type OutboxSnapshot } from './stateMath';
|
|
|
|
function stateWithQueue(queue: OrbitQueueItem[]): OrbitState {
|
|
return { ...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' }), queue };
|
|
}
|
|
|
|
describe('applyOutboxSnapshotsToState — queue history cap', () => {
|
|
it('drops the oldest entries when new suggestions push past the limit', () => {
|
|
// A full history (addedAt 0…limit-1) plus 10 brand-new suggestions.
|
|
const existing: OrbitQueueItem[] = Array.from({ length: ORBIT_QUEUE_HISTORY_LIMIT }, (_, i) => ({
|
|
trackId: `old-${i}`,
|
|
addedBy: 'old',
|
|
addedAt: i,
|
|
}));
|
|
const state = stateWithQueue(existing);
|
|
const now = 1_000_000;
|
|
const snapshots: OutboxSnapshot[] = [
|
|
{
|
|
user: 'bob',
|
|
outboxPlaylistId: 'ob',
|
|
trackIds: Array.from({ length: 10 }, (_, i) => `new-${i}`),
|
|
lastHeartbeat: now,
|
|
},
|
|
];
|
|
|
|
const next = applyOutboxSnapshotsToState(state, snapshots, now);
|
|
|
|
expect(next.queue.length).toBe(ORBIT_QUEUE_HISTORY_LIMIT);
|
|
// All 10 new suggestions survive…
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(next.queue.some(q => q.trackId === `new-${i}`)).toBe(true);
|
|
}
|
|
// …and the 10 oldest were evicted.
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(next.queue.some(q => q.trackId === `old-${i}`)).toBe(false);
|
|
}
|
|
// The youngest retained "old" entries are still present.
|
|
expect(next.queue.some(q => q.trackId === `old-${ORBIT_QUEUE_HISTORY_LIMIT - 1}`)).toBe(true);
|
|
});
|
|
|
|
it('leaves a sub-limit queue untouched', () => {
|
|
const state = stateWithQueue([{ trackId: 't0', addedBy: 'old', addedAt: 0 }]);
|
|
const now = 1_000_000;
|
|
const next = applyOutboxSnapshotsToState(
|
|
state,
|
|
[{ user: 'bob', outboxPlaylistId: 'ob', trackIds: ['t1'], lastHeartbeat: now }],
|
|
now,
|
|
);
|
|
expect(next.queue.map(q => q.trackId)).toEqual(['t0', 't1']);
|
|
});
|
|
});
|
|
|
|
describe('applyOutboxSnapshotsToState — maxUsers enforcement', () => {
|
|
const NOW = 5_000;
|
|
const fresh = (user: string, trackIds: string[] = []): OutboxSnapshot => ({
|
|
user,
|
|
outboxPlaylistId: `ob-${user}`,
|
|
trackIds,
|
|
lastHeartbeat: NOW,
|
|
});
|
|
|
|
function hostState(maxUsers: number, participants: OrbitState['participants']): OrbitState {
|
|
return {
|
|
...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh', maxUsers }),
|
|
participants,
|
|
};
|
|
}
|
|
|
|
it('admits at most maxUsers guests, dropping the surplus', () => {
|
|
const state = hostState(2, []);
|
|
const next = applyOutboxSnapshotsToState(
|
|
state,
|
|
[fresh('aaa'), fresh('bbb'), fresh('ccc')],
|
|
NOW,
|
|
);
|
|
// Three guests joined on the same tick; the deterministic username
|
|
// tie-break keeps the first two alphabetically.
|
|
expect(next.participants.map(p => p.user)).toEqual(['aaa', 'bbb']);
|
|
});
|
|
|
|
it('never displaces an established participant for a newcomer', () => {
|
|
// `zoe` joined earlier (smaller joinedAt) — she keeps her slot even though
|
|
// her name sorts last.
|
|
const state = hostState(2, [{ user: 'zoe', joinedAt: 1_000, lastHeartbeat: 1_000 }]);
|
|
const next = applyOutboxSnapshotsToState(
|
|
state,
|
|
[fresh('zoe'), fresh('aaa'), fresh('bbb')],
|
|
NOW,
|
|
);
|
|
expect(next.participants.map(p => p.user)).toEqual(['zoe', 'aaa']);
|
|
});
|
|
|
|
it('ignores suggestions from an over-cap guest', () => {
|
|
const state = hostState(2, []);
|
|
const next = applyOutboxSnapshotsToState(
|
|
state,
|
|
[fresh('aaa', ['t-a']), fresh('bbb', ['t-b']), fresh('ccc', ['t-c'])],
|
|
NOW,
|
|
);
|
|
const addedTracks = next.queue.map(q => q.trackId);
|
|
expect(addedTracks).toContain('t-a');
|
|
expect(addedTracks).toContain('t-b');
|
|
// ccc is over the cap → not a participant, suggestion ignored.
|
|
expect(addedTracks).not.toContain('t-c');
|
|
});
|
|
});
|