diff --git a/src/utils/orbit/cleanup.test.ts b/src/utils/orbit/cleanup.test.ts new file mode 100644 index 00000000..4a6e7803 --- /dev/null +++ b/src/utils/orbit/cleanup.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeInitialOrbitState } from '../../api/orbit'; + +const playlists = vi.hoisted(() => ({ getPlaylists: vi.fn(), deletePlaylist: vi.fn() })); +const lastSession = vi.hoisted(() => ({ readOrbitLastSessionSid: vi.fn() })); +const orbitStoreState = vi.hoisted(() => ({ sessionId: null as string | null })); + +vi.mock('../../api/subsonicPlaylists', () => playlists); +vi.mock('../../store/authStore', () => ({ + useAuthStore: { getState: () => ({ getActiveServer: () => ({ username: 'bob' }) }) }, +})); +vi.mock('../../store/orbitStore', () => ({ + useOrbitStore: { getState: () => orbitStoreState }, +})); +vi.mock('./lastSession', () => lastSession); + +import { cleanupOrphanedOrbitPlaylists } from './cleanup'; + +function sessionComment(sid: string, ageMs = 0): string { + const s = makeInitialOrbitState({ sid, host: 'bob', name: 'X' }); + return JSON.stringify({ ...s, positionAt: Date.now() - ageMs }); +} +const pl = (name: string, comment: string | undefined, id = name) => ({ + id, name, owner: 'bob', comment, changed: undefined as string | undefined, +}); + +beforeEach(() => { + vi.clearAllMocks(); + orbitStoreState.sessionId = null; + lastSession.readOrbitLastSessionSid.mockReturnValue(null); + playlists.deletePlaylist.mockResolvedValue(undefined); +}); + +describe('cleanupOrphanedOrbitPlaylists', () => { + it('does not prune a live session protected by the reconnect breadcrumb', async () => { + // Regression: the bare session name (`__psyorbit___`) must match the + // regex so the sid-skip is reached. A mis-anchored regex treated it as + // corrupt and deleted it before the breadcrumb guard — killing reconnect. + lastSession.readOrbitLastSessionSid.mockReturnValue('aaaa1111'); + playlists.getPlaylists.mockResolvedValue([ + pl('__psyorbit_aaaa1111__', sessionComment('aaaa1111', 60 * 60_000)), + pl('__psyorbit_aaaa1111_from_bob__', JSON.stringify({ ts: Date.now() - 60 * 60_000 })), + ]); + + const deleted = await cleanupOrphanedOrbitPlaylists(); + + expect(deleted).toBe(0); + expect(playlists.deletePlaylist).not.toHaveBeenCalled(); + }); + + it('recognises a fresh session playlist instead of pruning the bare name', async () => { + playlists.getPlaylists.mockResolvedValue([ + pl('__psyorbit_bbbb2222__', sessionComment('bbbb2222', 0)), + ]); + const deleted = await cleanupOrphanedOrbitPlaylists(); + expect(deleted).toBe(0); + expect(playlists.deletePlaylist).not.toHaveBeenCalled(); + }); + + it('prunes a stale, unprotected session', async () => { + playlists.getPlaylists.mockResolvedValue([ + pl('__psyorbit_cccc3333__', sessionComment('cccc3333', 60 * 60_000)), + ]); + const deleted = await cleanupOrphanedOrbitPlaylists(); + expect(deleted).toBe(1); + expect(playlists.deletePlaylist).toHaveBeenCalledWith('__psyorbit_cccc3333__'); + }); + + it('prunes a corrupt / unrecognised orbit name', async () => { + playlists.getPlaylists.mockResolvedValue([ + pl('__psyorbit_not-hex-no-suffix', undefined), + ]); + const deleted = await cleanupOrphanedOrbitPlaylists(); + expect(deleted).toBe(1); + }); +}); diff --git a/src/utils/orbit/cleanup.ts b/src/utils/orbit/cleanup.ts index 9e8d0b32..d868ec20 100644 --- a/src/utils/orbit/cleanup.ts +++ b/src/utils/orbit/cleanup.ts @@ -31,7 +31,12 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise { // the next sweep prunes it. const reconnectSid = readOrbitLastSessionSid(); - const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`); + // Matches both a session playlist (`__psyorbit___`) and an outbox + // (`__psyorbit__from___`). The trailing `__` must live outside the + // optional `_from_…` group, otherwise a bare session name fails to match and + // gets pruned as "corrupt" — which would delete a live session a restart is + // about to reconnect to. + const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`); let deleted = 0; for (const p of all) {