test(authStore): characterize login + servers + persistence + settings (Phase F2) (#543)

login.test.ts: composed addServer -> setActiveServer -> setLoggedIn flow,
failed-login leaves prior state intact, addServer assigns unique ids,
setConnecting / setConnectionError independence, logout clears
isLoggedIn + musicFolders but keeps server entry, Last.fm session
(setLastfm / connectLastfm / disconnectLastfm contracts).

servers.test.ts: addServer / updateServer (patch by id, no-op on unknown),
setActiveServer (clears musicFolders), removeServer (non-active no-effect,
active picks newServers[0] fallback, last server -> null + isLoggedIn
false, cleans per-server bookkeeping maps), getActiveServer / getBaseUrl
selectors. Includes the gapless / crossfade mutex regression test from
the v2 plan section 4.3 -- callers clear the other flag before setting,
the setters themselves do NOT auto-clear (contract pin).

persistence.test.ts: hydration loads existing localStorage shape, defaults
missing fields, preserves saved values verbatim. Robust to corrupt JSON
and missing top-level state. onRehydrate migrations: clears conflicting
hotCacheEnabled + preloadMode!=off legacy combo, keeps hotCache when
preload was already off, migrates legacy waveform seekbarStyle to truewave,
strips removed animationMode / reducedAnimations fields. partialize strips
musicFolders. Includes the synchronous-storage invariant regression from
the v2 plan section 2 (CLAUDE.md gotcha) -- getActiveServer is visible in
the same tick after addServer + setActiveServer with no await.

settings.test.ts: API-pin sweep across 22 trivial setters via it.each
(rename-resistant), focused tests for setters with logic (clamping in
setTrackPreviewStartRatio / setTrackPreviewDurationSec / setRandomMixSize,
boolean coercion in setTrackPreviewsEnabled, finite-number guard in
setLoudnessPreAnalysisAttenuationDb, default reset in
resetLoudnessPreAnalysisAttenuationDbDefault), per-server bookkeeping
contracts (setEntityRatingSupport / setAudiomuseNavidromeEnabled
positive-opt-in semantics), enum-value setters
(setPreloadMode / setDiscordCoverSource / setLoggingMode / setReplayGainMode /
setNormalizationEngine / setLyricsMode), genre blacklist + audio output
device replacement.

authStore.ts coverage 45.37% -> 79.29% lines (target was 60%). authStore.ts
not yet added to the hot-path gate -- want to see it stable across a few
real PRs first per the gate's curation rule.
This commit is contained in:
Frank Stellmacher
2026-05-11 22:30:45 +02:00
committed by GitHub
parent 8569a17797
commit ae23bf61eb
4 changed files with 839 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* Server add / remove / update / switch characterization for `authStore`.
*
* Also includes the gapless / crossfade mutual-exclusion regression test
* from §4.3 of the pre-refactor testing plan v2 — both flags live on
* `authStore`, both UI surfaces (Settings + QueuePanel toolbar) clear the
* other one before setting their own. A refactor that pushes mutex
* enforcement into the setters would change the contract; the tests pin
* the current caller-clears-first behaviour.
*
* Phase F2 / PR 3.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from './authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { makeServer } from '@/test/helpers/factories';
function addThree(): { a: string; b: string; c: string } {
const a = useAuthStore.getState().addServer({ name: 'A', url: 'https://a.test', username: 'u', password: 'p' });
const b = useAuthStore.getState().addServer({ name: 'B', url: 'https://b.test', username: 'u', password: 'p' });
const c = useAuthStore.getState().addServer({ name: 'C', url: 'https://c.test', username: 'u', password: 'p' });
return { a, b, c };
}
beforeEach(() => {
resetAuthStore();
});
describe('addServer / updateServer', () => {
it('appends a new server and returns its id', () => {
const id = useAuthStore.getState().addServer({
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
});
const s = useAuthStore.getState();
expect(s.servers).toHaveLength(1);
expect(s.servers[0]?.id).toBe(id);
expect(s.servers[0]?.name).toBe('Home');
});
it('updateServer patches the matching server only', () => {
const { a, b } = addThree();
useAuthStore.getState().updateServer(a, { name: 'A-renamed', url: 'https://a-new.test' });
const s = useAuthStore.getState();
const sa = s.servers.find(srv => srv.id === a);
const sb = s.servers.find(srv => srv.id === b);
expect(sa?.name).toBe('A-renamed');
expect(sa?.url).toBe('https://a-new.test');
expect(sb?.name).toBe('B');
expect(sb?.url).toBe('https://b.test');
});
it('updateServer is a no-op for an unknown id', () => {
const { a } = addThree();
const before = useAuthStore.getState().servers;
useAuthStore.getState().updateServer('unknown-id', { name: 'X' });
expect(useAuthStore.getState().servers).toEqual(before);
expect(useAuthStore.getState().servers.find(s => s.id === a)?.name).toBe('A');
});
});
describe('setActiveServer', () => {
it('updates activeServerId and clears musicFolders (forces a refetch)', () => {
const { a, b } = addThree();
useAuthStore.getState().setActiveServer(a);
useAuthStore.getState().setMusicFolders([{ id: 'mf-1', name: 'Music' }]);
useAuthStore.getState().setActiveServer(b);
const s = useAuthStore.getState();
expect(s.activeServerId).toBe(b);
expect(s.musicFolders).toEqual([]);
});
});
describe('removeServer', () => {
it('removes a non-active server without touching activeServerId or isLoggedIn', () => {
const { a, b } = addThree();
useAuthStore.getState().setActiveServer(a);
useAuthStore.getState().setLoggedIn(true);
useAuthStore.getState().removeServer(b);
const s = useAuthStore.getState();
expect(s.servers.map(srv => srv.id)).not.toContain(b);
expect(s.activeServerId).toBe(a);
expect(s.isLoggedIn).toBe(true);
});
it('removing the active server picks newServers[0] as the deterministic fallback', () => {
const { a, b, c } = addThree();
useAuthStore.getState().setActiveServer(a);
useAuthStore.getState().removeServer(a);
const s = useAuthStore.getState();
expect(s.servers.map(srv => srv.id)).toEqual([b, c]);
expect(s.activeServerId).toBe(b);
});
it('removing the only / active server clears activeServerId to null and forces logout', () => {
const id = useAuthStore.getState().addServer({ name: 'Solo', url: 'https://s.test', username: 'u', password: 'p' });
useAuthStore.getState().setActiveServer(id);
useAuthStore.getState().setLoggedIn(true);
useAuthStore.getState().removeServer(id);
const s = useAuthStore.getState();
expect(s.servers).toHaveLength(0);
expect(s.activeServerId).toBeNull();
expect(s.isLoggedIn).toBe(false);
});
it('cleans associated per-server bookkeeping maps (entityRatingSupport / instantMix / …)', () => {
const { a, b } = addThree();
useAuthStore.setState({
entityRatingSupportByServer: { [a]: 'full', [b]: 'track_only' },
});
useAuthStore.getState().removeServer(a);
expect(useAuthStore.getState().entityRatingSupportByServer).toEqual({ [b]: 'track_only' });
});
it('does not touch activeServerId when the removed id was inactive', () => {
const { a, b } = addThree();
useAuthStore.getState().setActiveServer(b);
useAuthStore.getState().removeServer(a);
expect(useAuthStore.getState().activeServerId).toBe(b);
});
});
describe('selectors — getBaseUrl / getActiveServer', () => {
it('getActiveServer returns the entry matching activeServerId', () => {
const { a, b } = addThree();
useAuthStore.getState().setActiveServer(b);
expect(useAuthStore.getState().getActiveServer()?.id).toBe(b);
});
it('getActiveServer returns undefined when no server is active', () => {
expect(useAuthStore.getState().getActiveServer()).toBeUndefined();
});
it('getBaseUrl strips trailing slashes and adds http:// when missing', () => {
useAuthStore.getState().addServer({ name: 'A', url: 'http://a.test/', username: 'u', password: 'p' });
const a = useAuthStore.getState().servers[0]!.id;
useAuthStore.getState().setActiveServer(a);
expect(useAuthStore.getState().getBaseUrl()).toBe('http://a.test');
const b = useAuthStore.getState().addServer({ name: 'B', url: 'b.local', username: 'u', password: 'p' });
useAuthStore.getState().setActiveServer(b);
expect(useAuthStore.getState().getBaseUrl()).toBe('http://b.local');
});
it('getBaseUrl returns empty string when no server is active', () => {
expect(useAuthStore.getState().getBaseUrl()).toBe('');
});
});
describe('audio modes — gapless / crossfade mutual exclusion (regression §4.3 of v2 plan)', () => {
it('enabling gapless after the caller clears crossfade yields gapless-only', () => {
useAuthStore.setState({ crossfadeEnabled: true, gaplessEnabled: false });
// Caller pattern matches Settings.tsx:2565 — gapless toggle clears crossfade first.
useAuthStore.getState().setCrossfadeEnabled(false);
useAuthStore.getState().setGaplessEnabled(true);
const s = useAuthStore.getState();
expect(s.crossfadeEnabled).toBe(false);
expect(s.gaplessEnabled).toBe(true);
});
it('enabling crossfade after the caller clears gapless yields crossfade-only', () => {
useAuthStore.setState({ crossfadeEnabled: false, gaplessEnabled: true });
useAuthStore.getState().setGaplessEnabled(false);
useAuthStore.getState().setCrossfadeEnabled(true);
const s = useAuthStore.getState();
expect(s.gaplessEnabled).toBe(false);
expect(s.crossfadeEnabled).toBe(true);
});
it('the setters themselves do NOT auto-clear the other flag — callers are responsible', () => {
// Pinning the current contract: a refactor that moves mutex enforcement
// into the setters would silently change behaviour for any caller that
// doesn't already clear first.
useAuthStore.setState({ crossfadeEnabled: true, gaplessEnabled: false });
useAuthStore.getState().setGaplessEnabled(true);
// Both end up true because the setter is a pure assignment.
expect(useAuthStore.getState().crossfadeEnabled).toBe(true);
expect(useAuthStore.getState().gaplessEnabled).toBe(true);
});
});