mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
c37d5f7389
The connection-reachability + Subsonic network-guard helpers were pinned low by four lib/api consumers (subsonicLibrary/Playlists/Ratings/Scrobble) yet ran two lower-layer→feature inversions. Both removed without a registry: - devOfflineBrowseStore is a self-contained DEV-only toggle with zero offline- feature coupling — it was only colocated there. Relocated features/offline/store → store/ (global). The @/features/offline barrel re-exports it so feature/UI consumers are unchanged; the three lower-layer readers (subsonicNetworkGuard, activeServerReachability, useConnectionStatus) now import it from @/store directly. - subsonicNetworkGuard's only other feature dep was playback's resolvePlaybackUrl, used solely for the psysonic-local:// skip check. Added hasLocalPlaybackUrl to the existing M4 substrate store/localPlaybackResolve — it mirrors resolvePlaybackUrl's local-source branch exactly (same profile resolution; the empty-serverId playback fallback never applies in the guard), so the skip stays bit-identical. network/ (subsonicNetworkGuard + activeServerReachability + tests) → lib/network, now @/features-free. useConnectionStatus is now iron-rule-clean and stays in hooks/ (cross-cutting). Test mocks retargeted to the new seam modules. Behavior-adjacent (covered by suite; default paths identical): the local-bytes skip helper — flag for offline-playback runtime QA alongside the M4 media-resolver seam.
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
|
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
|
import {
|
|
favoritesOfflineBrowseEnabled,
|
|
hasOfflineBrowsingContent,
|
|
} from '@/features/offline/utils/favoritesOfflineBrowse';
|
|
import {
|
|
isOfflineSidebarLibraryNavAllowed,
|
|
isOfflineSidebarNavAllowed,
|
|
isOfflineSidebarSystemNavAllowed,
|
|
} from '@/features/offline/utils/offlineNavPolicy';
|
|
import {
|
|
loadStarredFromLibraryIndex,
|
|
mergeStarredFromServers,
|
|
} from '@/features/offline/utils/offlineStarredLoad';
|
|
import { resolveAlbumForServer } from '@/features/offline/utils/offlineMediaResolve';
|
|
|
|
const isActiveServerReachableMock = vi.fn(() => true);
|
|
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
|
vi.mock('@/lib/network/activeServerReachability', () => ({
|
|
isActiveServerReachable: () => isActiveServerReachableMock(),
|
|
}));
|
|
vi.mock('@/lib/network/subsonicNetworkGuard', () => ({
|
|
shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) =>
|
|
shouldAttemptSubsonicForServerMock(serverId, trackId),
|
|
}));
|
|
|
|
const getAlbumForServerMock = vi.fn();
|
|
const libraryAdvancedSearchMock = vi.fn();
|
|
const libraryGetTracksByAlbumMock = vi.fn();
|
|
const libraryGetTracksBatchChunkedMock = vi.fn();
|
|
|
|
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
|
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
|
|
}));
|
|
|
|
vi.mock('@/lib/api/library', () => ({
|
|
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
|
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
|
libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args),
|
|
}));
|
|
|
|
describe('favoritesOfflineBrowse', () => {
|
|
beforeEach(() => {
|
|
isActiveServerReachableMock.mockReturnValue(true);
|
|
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
|
getAlbumForServerMock.mockReset();
|
|
libraryGetTracksByAlbumMock.mockReset();
|
|
libraryAdvancedSearchMock.mockReset();
|
|
libraryGetTracksBatchChunkedMock.mockReset();
|
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
|
useAuthStore.setState({
|
|
favoritesOfflineEnabled: false,
|
|
activeServerId: 'srv-1',
|
|
servers: [{ id: 'srv-1', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
|
});
|
|
useLocalPlaybackStore.setState({ entries: {} });
|
|
});
|
|
|
|
it('favoritesOfflineBrowseEnabled requires setting and at least one indexed server', () => {
|
|
expect(favoritesOfflineBrowseEnabled()).toBe(false);
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
expect(favoritesOfflineBrowseEnabled()).toBe(true);
|
|
useAuthStore.setState({ servers: [] });
|
|
expect(favoritesOfflineBrowseEnabled()).toBe(false);
|
|
useAuthStore.setState({
|
|
favoritesOfflineEnabled: true,
|
|
activeServerId: null,
|
|
servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }],
|
|
});
|
|
expect(favoritesOfflineBrowseEnabled()).toBe(true);
|
|
});
|
|
|
|
it('mergeStarredFromServers tags serverId and dedupes per server', () => {
|
|
const merged = mergeStarredFromServers([
|
|
{
|
|
serverId: 'srv-1',
|
|
starred: {
|
|
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', songCount: 1, duration: 1 }],
|
|
artists: [],
|
|
songs: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', duration: 1 }],
|
|
},
|
|
},
|
|
{
|
|
serverId: 'srv-2',
|
|
starred: {
|
|
albums: [{ id: 'alb-1', name: 'B', artist: 'Y', artistId: 'art-2', songCount: 1, duration: 1 }],
|
|
artists: [],
|
|
songs: [{ id: 't-1', title: 'S2', artist: 'Y', album: 'B', albumId: 'alb-1', duration: 1 }],
|
|
},
|
|
},
|
|
]);
|
|
expect(merged.albums).toHaveLength(2);
|
|
expect(merged.albums.map(a => a.serverId)).toEqual(['srv-1', 'srv-2']);
|
|
expect(merged.songs).toHaveLength(2);
|
|
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
|
|
});
|
|
|
|
it('isOfflineSidebarLibraryNavAllowed gates offline sidebar entries', () => {
|
|
expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false);
|
|
expect(isOfflineSidebarLibraryNavAllowed('artists', false, true)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, true)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, true)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, false)).toBe(false);
|
|
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, false)).toBe(false);
|
|
expect(isOfflineSidebarLibraryNavAllowed('offline', false, false)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, true)).toBe(true);
|
|
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, false)).toBe(false);
|
|
});
|
|
|
|
it('isOfflineSidebarSystemNavAllowed keeps help and player stats offline', () => {
|
|
expect(isOfflineSidebarSystemNavAllowed('help', false)).toBe(true);
|
|
expect(isOfflineSidebarSystemNavAllowed('statistics', true)).toBe(true);
|
|
expect(isOfflineSidebarSystemNavAllowed('statistics', false)).toBe(false);
|
|
expect(isOfflineSidebarNavAllowed('help', false, false, false)).toBe(true);
|
|
expect(isOfflineSidebarNavAllowed('statistics', false, false, true)).toBe(true);
|
|
expect(isOfflineSidebarNavAllowed('tracks', false, true, false)).toBe(true);
|
|
expect(isOfflineSidebarNavAllowed('playlists', false, false, false, true)).toBe(true);
|
|
});
|
|
|
|
it('loadStarredFromLibraryIndex uses starred advanced search when not offline-bytes', async () => {
|
|
libraryAdvancedSearchMock.mockResolvedValue({
|
|
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }],
|
|
tracks: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', durationSec: 1, serverId: 'srv-1' }],
|
|
artists: [],
|
|
});
|
|
|
|
const starred = await loadStarredFromLibraryIndex('srv-1');
|
|
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
serverId: 'srv-1',
|
|
entityTypes: ['album', 'track'],
|
|
starredOnly: true,
|
|
}));
|
|
expect(libraryGetTracksBatchChunkedMock).not.toHaveBeenCalled();
|
|
expect(starred.artists).toEqual([]);
|
|
expect(starred.songs).toHaveLength(1);
|
|
});
|
|
|
|
it('loadStarredFromLibraryIndex prefers local bytes then starred filter when offline', async () => {
|
|
useLocalPlaybackStore.setState({
|
|
entries: {
|
|
'a.test:t1': {
|
|
serverIndexKey: 'a.test',
|
|
trackId: 't1',
|
|
localPath: '/media/library/a.test/a/al/t1.mp3',
|
|
layoutFingerprint: 'fp',
|
|
sizeBytes: 1,
|
|
tier: 'favorite-auto',
|
|
cachedAt: 1,
|
|
suffix: 'mp3',
|
|
},
|
|
'a.test:t2': {
|
|
serverIndexKey: 'a.test',
|
|
trackId: 't2',
|
|
localPath: '/media/library/a.test/a/al/t2.mp3',
|
|
layoutFingerprint: 'fp',
|
|
sizeBytes: 1,
|
|
tier: 'favorite-auto',
|
|
cachedAt: 1,
|
|
suffix: 'mp3',
|
|
},
|
|
},
|
|
});
|
|
libraryGetTracksBatchChunkedMock.mockResolvedValue([
|
|
{
|
|
id: 't1',
|
|
title: 'Starred',
|
|
artist: 'X',
|
|
album: 'A',
|
|
albumId: 'alb-1',
|
|
durationSec: 1,
|
|
starredAt: 1,
|
|
serverId: 'srv-1',
|
|
},
|
|
{
|
|
id: 't2',
|
|
title: 'Not starred',
|
|
artist: 'X',
|
|
album: 'A',
|
|
albumId: 'alb-1',
|
|
durationSec: 1,
|
|
serverId: 'srv-1',
|
|
},
|
|
]);
|
|
libraryAdvancedSearchMock.mockResolvedValue({
|
|
albums: [{
|
|
id: 'alb-2',
|
|
name: 'Album star only',
|
|
artist: 'Y',
|
|
artistId: 'art-2',
|
|
starredAt: 1,
|
|
serverId: 'srv-1',
|
|
}],
|
|
artists: [],
|
|
tracks: [],
|
|
});
|
|
|
|
const starred = await loadStarredFromLibraryIndex('srv-1', true);
|
|
|
|
expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalled();
|
|
expect(libraryAdvancedSearchMock).toHaveBeenCalled();
|
|
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
serverId: 'srv-1',
|
|
entityTypes: ['album'],
|
|
starredOnly: true,
|
|
restrictAlbumIds: ['alb-1'],
|
|
}));
|
|
expect(starred.songs).toHaveLength(1);
|
|
expect(starred.songs[0]?.id).toBe('t1');
|
|
expect(starred.albums.map(a => a.id).sort()).toEqual(['alb-1', 'alb-2']);
|
|
});
|
|
|
|
it('resolveAlbumForServer uses library index when network fails', async () => {
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
|
getAlbumForServerMock.mockRejectedValue(new Error('Network Error'));
|
|
libraryGetTracksByAlbumMock.mockResolvedValue([
|
|
{
|
|
id: 't1',
|
|
title: 'Track',
|
|
artist: 'Artist',
|
|
album: 'Album',
|
|
albumId: 'alb-1',
|
|
artistId: 'art-1',
|
|
durationSec: 200,
|
|
serverId: 'srv-1',
|
|
},
|
|
]);
|
|
libraryAdvancedSearchMock.mockResolvedValue({
|
|
albums: [{
|
|
id: 'alb-1',
|
|
name: 'Album',
|
|
artist: 'Artist',
|
|
artistId: 'art-1',
|
|
serverId: 'srv-1',
|
|
}],
|
|
artists: [],
|
|
tracks: [],
|
|
});
|
|
|
|
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
|
expect(result?.album.id).toBe('alb-1');
|
|
expect(result?.songs).toHaveLength(1);
|
|
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
|
});
|
|
|
|
it('resolveAlbumForServer prefers full network album over partial library index', async () => {
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
|
libraryGetTracksByAlbumMock.mockResolvedValue([
|
|
{
|
|
id: 't1',
|
|
title: 'Indexed only',
|
|
artist: 'Artist',
|
|
album: 'Album',
|
|
albumId: 'alb-1',
|
|
durationSec: 100,
|
|
serverId: 'srv-1',
|
|
},
|
|
]);
|
|
getAlbumForServerMock.mockResolvedValue({
|
|
album: { id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'art-1', songCount: 3, duration: 600 },
|
|
songs: [
|
|
{ id: 't1', title: 'One', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
|
{ id: 't2', title: 'Two', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
|
{ id: 't3', title: 'Three', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
|
],
|
|
});
|
|
|
|
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
|
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
|
expect(result?.songs).toHaveLength(3);
|
|
expect(result?.songs.map(s => s.id)).toEqual(['t1', 't2', 't3']);
|
|
});
|
|
|
|
it('resolveAlbumForServer uses library index when server is unreachable', async () => {
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
shouldAttemptSubsonicForServerMock.mockReturnValue(false);
|
|
libraryGetTracksByAlbumMock.mockResolvedValue([
|
|
{
|
|
id: 't1',
|
|
title: 'Offline track',
|
|
artist: 'Artist',
|
|
album: 'Album',
|
|
albumId: 'alb-1',
|
|
durationSec: 200,
|
|
serverId: 'srv-1',
|
|
},
|
|
]);
|
|
libraryAdvancedSearchMock.mockResolvedValue({
|
|
albums: [{
|
|
id: 'alb-1',
|
|
name: 'Album',
|
|
artist: 'Artist',
|
|
artistId: 'art-1',
|
|
serverId: 'srv-1',
|
|
}],
|
|
artists: [],
|
|
tracks: [],
|
|
});
|
|
|
|
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
|
expect(result?.songs).toHaveLength(1);
|
|
expect(getAlbumForServerMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolveAlbumForServer falls back to network when index misses', async () => {
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
isActiveServerReachableMock.mockReturnValue(true);
|
|
libraryGetTracksByAlbumMock.mockResolvedValue([]);
|
|
getAlbumForServerMock.mockResolvedValue({
|
|
album: { id: 'alb-2', name: 'Net', artist: 'A', artistId: 'a1', songCount: 1, duration: 1 },
|
|
songs: [{ id: 't2', title: 'T', artist: 'A', album: 'Net', albumId: 'alb-2', duration: 1 }],
|
|
});
|
|
|
|
const result = await resolveAlbumForServer('srv-1', 'alb-2');
|
|
expect(result?.album.id).toBe('alb-2');
|
|
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2');
|
|
});
|
|
|
|
it('hasOfflineBrowsingContent includes favorite-auto bytes when browse is enabled', () => {
|
|
expect(hasOfflineBrowsingContent({})).toBe(false);
|
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
|
useLocalPlaybackStore.setState({
|
|
entries: {
|
|
'a.test:t1': {
|
|
serverIndexKey: 'a.test',
|
|
trackId: 't1',
|
|
localPath: '/fav/t1.mp3',
|
|
layoutFingerprint: 'fp',
|
|
sizeBytes: 1,
|
|
tier: 'favorite-auto',
|
|
cachedAt: 1,
|
|
suffix: 'mp3',
|
|
},
|
|
},
|
|
});
|
|
expect(hasOfflineBrowsingContent({})).toBe(true);
|
|
});
|
|
});
|