mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
test(scenario): server switch x active orbit + queue
Cover switchActiveServer end to end with the heavy deps mocked and the real orbit + auth stores driving assertions: unreachable aborts; host tears down via endOrbitSession, guest via leaveOrbitSession, both reset the role; the old server's queue is flushed, the active server rebinds, and a queue handoff is marked. Closes the switchActiveServer QA.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Scenario: server switch × active orbit + playing queue (closes the switchActiveServer
|
||||
// QA). Switching servers must tear down a live orbit session (its playlists live on the
|
||||
// old backend), flush the old server's play queue, rebind the active server, and mark a
|
||||
// queue handoff. Deep modules are mocked (not the @/features/orbit barrel) to avoid
|
||||
// partial-barrel collapse; the real orbit + auth stores drive the observable assertions.
|
||||
|
||||
const ensureConnectUrlResolved = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/server/serverEndpoint', async (io) => ({
|
||||
...(await io<typeof import('@/lib/server/serverEndpoint')>()),
|
||||
ensureConnectUrlResolved,
|
||||
}));
|
||||
|
||||
const flushPlayQueueForServer = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock('@/features/playback/store/queueSync', async (io) => ({
|
||||
...(await io<typeof import('@/features/playback/store/queueSync')>()),
|
||||
flushPlayQueueForServer,
|
||||
}));
|
||||
|
||||
const markQueueHandoffPending = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/features/playback/store/queueSyncUiState', async (io) => ({
|
||||
...(await io<typeof import('@/features/playback/store/queueSyncUiState')>()),
|
||||
markQueueHandoffPending,
|
||||
}));
|
||||
|
||||
// Fire-and-forget from switchActiveServer (`void`); stub so its real IPC/header
|
||||
// wiring doesn't surface as an unhandled rejection after the assertion.
|
||||
vi.mock('@/lib/server/syncServerHttpContext', async (io) => ({
|
||||
...(await io<typeof import('@/lib/server/syncServerHttpContext')>()),
|
||||
syncServerHttpContextForProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
const endOrbitSession = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock('@/features/orbit/utils/host', async (io) => ({
|
||||
...(await io<typeof import('@/features/orbit/utils/host')>()),
|
||||
endOrbitSession,
|
||||
}));
|
||||
|
||||
const leaveOrbitSession = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock('@/features/orbit/utils/guest', async (io) => ({
|
||||
...(await io<typeof import('@/features/orbit/utils/guest')>()),
|
||||
leaveOrbitSession,
|
||||
}));
|
||||
|
||||
import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
|
||||
const oldServer = makeServer({ id: 'old', url: 'https://old.example' });
|
||||
const newServer = makeServer({ id: 'new', url: 'https://new.example' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
useAuthStore.setState({ servers: [oldServer, newServer], activeServerId: oldServer.id });
|
||||
ensureConnectUrlResolved.mockResolvedValue({
|
||||
ok: true,
|
||||
baseUrl: 'https://new.example',
|
||||
ping: { type: 'navidrome', serverVersion: '1.0.0', openSubsonic: true },
|
||||
});
|
||||
});
|
||||
|
||||
describe('server switch × active orbit + playing queue', () => {
|
||||
it('aborts when the new server is unreachable — no teardown, no rebind', async () => {
|
||||
ensureConnectUrlResolved.mockResolvedValue({ ok: false, reason: 'unreachable' });
|
||||
const ok = await switchActiveServer(newServer);
|
||||
expect(ok).toBe(false);
|
||||
expect(flushPlayQueueForServer).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().activeServerId).toBe(oldServer.id);
|
||||
});
|
||||
|
||||
it('as host: tears down orbit, flushes the old queue, rebinds, marks handoff', async () => {
|
||||
useOrbitStore.setState({ role: 'host', phase: 'active' });
|
||||
const ok = await switchActiveServer(newServer);
|
||||
expect(ok).toBe(true);
|
||||
expect(endOrbitSession).toHaveBeenCalledTimes(1);
|
||||
expect(leaveOrbitSession).not.toHaveBeenCalled();
|
||||
expect(useOrbitStore.getState().role).toBeNull();
|
||||
expect(flushPlayQueueForServer).toHaveBeenCalledWith(oldServer.id);
|
||||
expect(useAuthStore.getState().activeServerId).toBe(newServer.id);
|
||||
expect(markQueueHandoffPending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('as guest: leaves the session instead of ending it', async () => {
|
||||
useOrbitStore.setState({ role: 'guest', phase: 'active' });
|
||||
const ok = await switchActiveServer(newServer);
|
||||
expect(ok).toBe(true);
|
||||
expect(leaveOrbitSession).toHaveBeenCalledTimes(1);
|
||||
expect(endOrbitSession).not.toHaveBeenCalled();
|
||||
expect(useOrbitStore.getState().role).toBeNull();
|
||||
});
|
||||
|
||||
it('with no orbit session: still rebinds + flushes, no teardown call', async () => {
|
||||
const ok = await switchActiveServer(newServer);
|
||||
expect(ok).toBe(true);
|
||||
expect(endOrbitSession).not.toHaveBeenCalled();
|
||||
expect(leaveOrbitSession).not.toHaveBeenCalled();
|
||||
expect(flushPlayQueueForServer).toHaveBeenCalledWith(oldServer.id);
|
||||
expect(useAuthStore.getState().activeServerId).toBe(newServer.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user