test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList

The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
This commit is contained in:
Psychotoxical
2026-06-30 01:08:35 +02:00
parent 321d5ff6ab
commit 17ea96dcdb
45 changed files with 4 additions and 1 deletions
-79
View File
@@ -1,79 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: vi.fn(),
libraryFilterParams: () => ({}),
libraryFilterParamsForServer: () => ({}),
}));
vi.mock('./subsonicLibrary', () => ({
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
similarSongsRequestCount: (count: number) => count,
}));
import { api } from './subsonicClient';
import { fetchSimilarTracksRouted } from './subsonicArtists';
import { useAuthStore } from '../store/authStore';
const SID = 'srv-router';
const apiMock = vi.mocked(api);
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
useAuthStore.setState({
activeServerId: SID,
subsonicServerIdentityByServer: { [SID]: identity as never },
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
...probes,
} as never);
}
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
describe('fetchSimilarTracksRouted', () => {
beforeEach(() => {
apiMock.mockReset();
});
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['sonic-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('falls back to legacy when sonic returns empty', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'absent' },
});
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
});
-159
View File
@@ -1,159 +0,0 @@
import { useAuthStore } from '../store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { filterSongsToServerLibrary } from './subsonicLibrary';
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
OP_SIMILAR_TRACKS,
} from '../serverCapabilities/catalog';
import { resolveCallRoutesForServer } from '../serverCapabilities/storeView';
import type {
SubsonicAlbum,
SubsonicArtist,
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
export async function getArtists(): Promise<SubsonicArtist[]> {
type ArtistIndexEntry = { artist?: SubsonicArtist | SubsonicArtist[] };
const data = await api<{ artists?: { index?: ArtistIndexEntry | ArtistIndexEntry[] } }>('getArtists.view', {
...libraryFilterParams(),
});
const rawIdx = data.artists?.index;
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
const artists: SubsonicArtist[] = [];
for (const idx of indices) {
const rawArt = idx.artist;
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
artists.push(...arr);
}
return artists;
}
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id });
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
}
export async function getArtistForServer(
serverId: string,
id: string,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiForServer<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(serverId, 'getArtist.view', { id });
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
}
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
return data.artistInfo2 ?? {};
}
export async function getArtistInfoForServer(
serverId: string,
id: string,
options?: { similarArtistCount?: number },
): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await apiForServer<{ artistInfo2: SubsonicArtistInfo }>(
serverId,
'getArtistInfo2.view',
{ id, count, ...libraryFilterParamsForServer(serverId) },
);
return data.artistInfo2 ?? {};
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
}
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: topCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.topSongs?.song ?? [];
const filtered = await filterSongsToServerLibrary(raw, serverId);
return filtered.slice(0, 5);
} catch {
return [];
}
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
const raw = data.similarSongs2?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, count);
} catch {
return [];
}
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
const raw = data.similarSongs?.song;
if (!raw) return [];
const list = Array.isArray(raw) ? raw : [raw];
const filtered = await filterSongsToActiveLibrary(list);
return filtered.slice(0, count);
} catch {
return [];
}
}
/**
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
* extension (Navidrome ≥ 0.62 + AudioMuse plugin). Returns `[]` when the server
* has no provider (HTTP 404) so callers can fall back.
*/
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
'getSonicSimilarTracks.view',
{ id, count: requestCount, ...libraryFilterParams() },
);
const raw = data.sonicMatch;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
if (songs.length === 0) return [];
const filtered = await filterSongsToActiveLibrary(songs);
return filtered.slice(0, count);
} catch {
return [];
}
}
/**
* Capability-routed similar tracks for the active server. Prefers the sonic
* similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62),
* falling back to legacy `getSimilarSongs` on empty/unavailable.
*/
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return getSimilarSongs(songId, count);
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
if (routes.length === 0) return getSimilarSongs(songId, count);
for (const route of routes) {
const songs = route.transport === 'opensubsonic'
? await getSonicSimilarTracks(songId, count)
: await getSimilarSongs(songId, count);
if (songs.length > 0) return songs;
}
return [];
}