From 577b1ec7cfb5e79555d97f3e333fd904233c8956 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:00:13 +0200 Subject: [PATCH] test(store): persist upgrade/merge Guard shape drift when an old persisted blob rehydrates. Cover the analysisStrategyStore v0 to v1 migrate (preserves strategy, clamps parallelism, adds the per-server maps) plus a v1 passthrough, and add cold-start rehydrate round-trips for localPlaybackStore (entries) and offlineStore (albums). --- src/store/persistUpgrade.test.ts | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/store/persistUpgrade.test.ts diff --git a/src/store/persistUpgrade.test.ts b/src/store/persistUpgrade.test.ts new file mode 100644 index 00000000..9c9f4ee6 --- /dev/null +++ b/src/store/persistUpgrade.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAnalysisStrategyStore } from './analysisStrategyStore'; +import { useLocalPlaybackStore } from './localPlaybackStore'; +import { useOfflineStore } from '@/features/offline/store/offlineStore'; + +// Persist keys are byte-identical base↔branch (review §1), so the risk these guard +// is SHAPE drift when an old on-disk blob rehydrates into the current store — the +// silent data-loss vector in a store-moving refactor. + +beforeEach(() => { + localStorage.clear(); +}); + +describe('analysisStrategyStore persist migrate (v0 → v1)', () => { + it('upgrades a pre-version blob: preserves strategy, clamps parallelism, adds per-server maps', async () => { + // A v0 blob predates the per-server override maps and never clamped stored values. + localStorage.setItem( + 'psysonic-analytics-strategy', + JSON.stringify({ state: { strategy: 'advanced', advancedParallelism: 50 }, version: 0 }), + ); + await useAnalysisStrategyStore.persist.rehydrate(); + const s = useAnalysisStrategyStore.getState(); + expect(s.strategy).toBe('advanced'); // preserved (not the 'lazy' default) + expect(s.advancedParallelism).toBe(20); // clamped to the [1, 20] max + expect(s.strategyByServer).toEqual({}); // added by the migrate + expect(s.advancedParallelismByServer).toEqual({}); // added by the migrate + }); + + it('passes a current (v1) blob through with its per-server maps intact', async () => { + localStorage.setItem( + 'psysonic-analytics-strategy', + JSON.stringify({ + state: { + strategy: 'lazy', + advancedParallelism: 4, + strategyByServer: { s1: 'advanced' }, + advancedParallelismByServer: { s1: 3 }, + }, + version: 1, + }), + ); + await useAnalysisStrategyStore.persist.rehydrate(); + const s = useAnalysisStrategyStore.getState(); + expect(s.strategyByServer).toEqual({ s1: 'advanced' }); + expect(s.advancedParallelismByServer).toEqual({ s1: 3 }); + }); +}); + +describe('localPlaybackStore rehydrate (partialize: entries)', () => { + it('restores persisted entries on cold start', async () => { + // Write through the real persist path, then simulate a cold start and rehydrate. + useLocalPlaybackStore.getState().upsertEntry({ + serverIndexKey: 'k', + trackId: 't1', + localPath: '/disk/t1.opus', + layoutFingerprint: 'fp', + sizeBytes: 1, + suffix: 'opus', + tier: 'library', + }); + const persisted = localStorage.getItem('psysonic-local-playback'); + expect(persisted).toBeTruthy(); + + useLocalPlaybackStore.setState({ entries: {} }); // cold start (also re-persists empty) + localStorage.setItem('psysonic-local-playback', persisted!); // restore the on-disk blob + expect(useLocalPlaybackStore.getState().getEntry('t1', 'k')).toBeNull(); + + await useLocalPlaybackStore.persist.rehydrate(); + expect(useLocalPlaybackStore.getState().getEntry('t1', 'k')?.localPath).toBe('/disk/t1.opus'); + }); +}); + +describe('offlineStore rehydrate (partialize: albums)', () => { + it('restores persisted albums on cold start', async () => { + const albums = { 's1:alb1': { id: 'alb1', name: 'X', artist: 'Y', tracks: [] } }; + useOfflineStore.setState({ albums: {} }); // clean baseline (re-persists empty) + localStorage.setItem('psysonic-offline', JSON.stringify({ state: { albums }, version: 0 })); + await useOfflineStore.persist.rehydrate(); + expect(useOfflineStore.getState().albums).toEqual(albums); + }); +});