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).
This commit is contained in:
Psychotoxical
2026-07-01 12:00:13 +02:00
parent 9cb55cbcc5
commit 577b1ec7cf
+81
View File
@@ -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);
});
});