Files
Psychotoxical-psysonic/src/features/playback/store/pendingStarSync.test.ts
T

126 lines
4.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const starMock = vi.fn();
const unstarMock = vi.fn();
const setRatingMock = vi.fn();
vi.mock('@/lib/api/subsonicStarRating', () => ({
star: (...a: unknown[]) => starMock(...a),
unstar: (...a: unknown[]) => unstarMock(...a),
setRating: (...a: unknown[]) => setRatingMock(...a),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/lib/media/trackTypes';
import {
resetActiveServerConnectionSnapshot,
setActiveServerReachable,
} from '@/lib/network/activeServerReachability';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from '@/features/playback/store/pendingStarSync';
import {
getCachedTrack,
seedQueueResolver,
_resetQueueResolverForTest,
} from '@/features/playback/store/queueTrackResolver';
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
const track = (id: string): Track => ({
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
});
describe('pendingStarSync', () => {
beforeEach(() => {
vi.useFakeTimers();
resetActiveServerConnectionSnapshot();
setActiveServerReachable(true);
starMock.mockReset().mockResolvedValue(undefined);
unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined);
_resetPendingStarSyncForTest();
_resetQueueResolverForTest();
// Thin-state: the queue's track copy lives in the resolver cache. Seed it so
// a star/rating success has a cached entry to patch in place.
seedQueueResolver('', [track('t1')]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: toQueueItemRefs('', [track('t1')]),
queueServerId: null,
starredOverrides: {},
userRatingOverrides: {},
});
});
afterEach(() => {
_resetPendingStarSyncForTest();
vi.useRealTimers();
});
it('stars optimistically, then keeps the override + patches the track on success', async () => {
queueSongStar('t1', true);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // optimistic, instant
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song', undefined);
const s = usePlayerStore.getState();
expect(s.starredOverrides.t1).toBe(true); // kept on success so list views stay in sync
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
// Thin-state: the resolver cache entry is patched in place (not dropped) so
// the visible queue row keeps its title and reflects the synced star —
// dropping it would blank the row to a "…" placeholder.
const cached = getCachedTrack({ serverId: '', trackId: 't1' });
expect(cached?.title).toBe('t1');
expect(cached?.starred).toBeTruthy();
});
it('does NOT roll back on a network failure and keeps retrying', async () => {
starMock.mockRejectedValue(new Error('offline'));
queueSongStar('t1', true);
await vi.advanceTimersByTimeAsync(4000); // 0ms + 1s + 2s backoff cycles
expect(starMock.mock.calls.length).toBeGreaterThanOrEqual(2); // retried
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback)
});
it('flushes pending stars when the active server becomes reachable', async () => {
starMock.mockRejectedValue(new Error('offline'));
queueSongStar('t1', true);
await vi.advanceTimersByTimeAsync(0);
expect(starMock).toHaveBeenCalledTimes(1);
setActiveServerReachable(false);
setActiveServerReachable(true);
await vi.advanceTimersByTimeAsync(0);
expect(starMock.mock.calls.length).toBeGreaterThan(1);
});
it('passes serverId through to star/unstar for cross-server favorites', async () => {
queueSongStar('t1', true, 'srv-b');
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song', { serverId: 'srv-b' });
});
it('latest toggle wins when re-queued before sync', async () => {
queueSongStar('t1', true);
queueSongStar('t1', false); // user toggled back off
await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('t1', 'song', undefined);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(false); // kept as durable false
expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy();
});
it('rates optimistically (track patched), clears override on success', async () => {
queueSongRating('t1', 4);
// setUserRatingOverride patches the track immediately:
expect(usePlayerStore.getState().currentTrack?.userRating).toBe(4);
expect(usePlayerStore.getState().userRatingOverrides.t1).toBe(4);
await vi.runAllTimersAsync();
expect(setRatingMock).toHaveBeenCalledWith('t1', 4);
const s = usePlayerStore.getState();
expect('t1' in s.userRatingOverrides).toBe(false); // cleared
expect(s.currentTrack?.userRating).toBe(4); // track stays patched
});
});