Files
Psychotoxical-psysonic/src/api/subsonic.async.test.ts
T
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:

- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
  `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
  for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
  `setRating`, `probeEntityRatingSupport`. `setRating` still triggers
  the lazy `navidromeBrowse` cache invalidation; the same-folder
  lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
  `getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.

63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.

Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
2026-05-13 00:46:13 +02:00

438 lines
16 KiB
TypeScript

/**
* Async-endpoint contract tests for `subsonic.ts` (F3 follow-up).
*
* Mocks `axios` at the module boundary and verifies the response
* normalization paths the rest of the app depends on:
* - subsonic-response unwrap + status=ok check
* - error mapping (no envelope, status=failed, network error)
* - song-array vs single-object normalization (Subsonic's XML→JSON
* emits one-element collections as a bare object)
* - empty / missing collection fallbacks → []
* - explicit-credential variants (pingWithCredentials)
*
* Pure URL builders + auth params are pinned in
* `subsonic.contract.test.ts`. This file complements that with the
* networking surface the structural tests skipped.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
import { getStarred } from './subsonicStarRating';
import { search } from './subsonicSearch';
import { getAlbum, getMusicDirectory, getMusicFolders, getMusicIndexes, getRandomSongs, getSong } from './subsonicLibrary';
import { getArtists, getTopSongs } from './subsonicArtists';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
function setUpServer(): string {
const id = useAuthStore.getState().addServer({
name: 'Test', url: 'https://music.example.com', username: 'alice', password: 'pw',
});
useAuthStore.getState().setActiveServer(id);
return id;
}
function okResponse(payload: Record<string, unknown>) {
return {
data: {
'subsonic-response': { status: 'ok', ...payload },
},
};
}
function errorResponse(message = 'Subsonic error', code = 50) {
return {
data: {
'subsonic-response': {
status: 'failed',
error: { code, message },
},
},
};
}
beforeEach(() => {
resetAuthStore();
setUpServer();
vi.mocked(axios.get).mockReset();
});
afterEach(() => {
vi.mocked(axios.get).mockReset();
});
describe('api() helper — response envelope', () => {
it('unwraps the subsonic-response envelope on status=ok', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({ randomSongs: { song: [] } }),
);
await expect(getRandomSongs()).resolves.toEqual([]);
});
it('throws "Invalid response" when there is no subsonic-response envelope', async () => {
vi.mocked(axios.get).mockResolvedValue({ data: { not: 'subsonic' } });
await expect(getRandomSongs()).rejects.toThrow(/invalid response/i);
});
it('throws the server error message when status=failed', async () => {
vi.mocked(axios.get).mockResolvedValue(errorResponse('Wrong username or password'));
await expect(getRandomSongs()).rejects.toThrow(/wrong username/i);
});
it('throws a generic message when status=failed without an error.message', async () => {
vi.mocked(axios.get).mockResolvedValue({
data: { 'subsonic-response': { status: 'failed', error: {} } },
});
await expect(getRandomSongs()).rejects.toThrow(/subsonic api error/i);
});
it('propagates network failures (axios reject) to the caller', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('ECONNREFUSED'));
await expect(getRandomSongs()).rejects.toThrow(/ECONNREFUSED/);
});
});
describe('getRandomSongs — pass-through behaviour', () => {
// Navidrome's randomSongs endpoint always returns the `song` field as
// an array (`getRandomSongs` doesn't normalize single objects). The
// tests below pin the array + empty-fallback paths; the single-object
// path is intentionally untested because no production server returns it.
it('returns an empty array when randomSongs.song is missing entirely', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: {} }));
const songs = await getRandomSongs();
expect(songs).toEqual([]);
});
it('passes a song-array through unchanged', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
randomSongs: {
song: [
{ id: 'a', title: 'A', artist: 'Ar', album: 'Al', albumId: 'al-1', duration: 100 },
{ id: 'b', title: 'B', artist: 'Ar', album: 'Al', albumId: 'al-1', duration: 110 },
],
},
}),
);
const songs = await getRandomSongs();
expect(songs).toHaveLength(2);
expect(songs.map(s => s.id)).toEqual(['a', 'b']);
});
it('forwards a custom size to the query params', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: { song: [] } }));
await getRandomSongs(25);
const params = vi.mocked(axios.get).mock.calls[0]?.[1]?.params as Record<string, unknown>;
expect(params.size).toBe(25);
});
it('forwards a genre filter when provided', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: { song: [] } }));
await getRandomSongs(10, 'Rock');
const params = vi.mocked(axios.get).mock.calls[0]?.[1]?.params as Record<string, unknown>;
expect(params.genre).toBe('Rock');
});
});
describe('getAlbum', () => {
it('splits the response into { album, songs }', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
album: {
id: 'al-1', name: 'Album', artist: 'Ar', artistId: 'ar-1', songCount: 2, duration: 200,
song: [
{ id: 's1', title: 'S1', artist: 'Ar', album: 'Album', albumId: 'al-1', duration: 100 },
{ id: 's2', title: 'S2', artist: 'Ar', album: 'Album', albumId: 'al-1', duration: 100 },
],
},
}),
);
const { album, songs } = await getAlbum('al-1');
expect(album.id).toBe('al-1');
expect(album.songCount).toBe(2);
expect(songs).toHaveLength(2);
// The destructure removes `song` from the album payload.
expect((album as unknown as Record<string, unknown>).song).toBeUndefined();
});
it('returns an empty songs array when the album has no songs', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
album: { id: 'al-empty', name: 'Empty', artist: 'Ar', artistId: 'ar-1', songCount: 0, duration: 0 },
}),
);
const { songs } = await getAlbum('al-empty');
expect(songs).toEqual([]);
});
});
describe('getMusicDirectory + getMusicIndexes + getMusicFolders', () => {
it('getMusicDirectory normalizes child to an array', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
directory: {
id: 'd1', name: 'Music',
// Single child as object (not array).
child: { id: 'c1', title: 'Sub', isDir: true },
},
}),
);
const dir = await getMusicDirectory('d1');
expect(dir.id).toBe('d1');
expect(dir.child).toHaveLength(1);
expect(dir.child[0]?.id).toBe('c1');
});
it('getMusicDirectory returns empty child[] when the field is absent', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({ directory: { id: 'd2', name: 'Empty' } }),
);
const dir = await getMusicDirectory('d2');
expect(dir.child).toEqual([]);
});
it('getMusicIndexes flattens nested artist arrays into directory entries', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
indexes: {
index: [
{ name: 'A', artist: [{ id: 'ar-a1', name: 'A1' }, { id: 'ar-a2', name: 'A2' }] },
{ name: 'B', artist: { id: 'ar-b1', name: 'B1' } },
],
},
}),
);
const entries = await getMusicIndexes('1');
expect(entries.map(e => e.id)).toEqual(['ar-a1', 'ar-a2', 'ar-b1']);
});
it('getMusicIndexes handles a missing index field as empty', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ indexes: {} }));
expect(await getMusicIndexes('1')).toEqual([]);
});
it('getMusicFolders coerces numeric ids to strings + defaults the name', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
musicFolders: {
musicFolder: [
{ id: 1, name: 'Music' },
{ id: 2 }, // missing name → default
],
},
}),
);
const folders = await getMusicFolders();
expect(folders).toEqual([
{ id: '1', name: 'Music' },
{ id: '2', name: 'Library' },
]);
});
});
describe('getSong', () => {
it('returns the song payload', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({ song: { id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 } }),
);
const song = await getSong('s1');
expect(song?.id).toBe('s1');
});
it('returns null on any failure (network or subsonic error)', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
await expect(getSong('whatever')).resolves.toBeNull();
});
});
describe('getStarred', () => {
it('returns empty arrays when starred2 has no entries', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ starred2: {} }));
const result = await getStarred();
expect(result.songs).toEqual([]);
expect(result.albums).toEqual([]);
expect(result.artists).toEqual([]);
});
it('returns empty arrays when starred2 itself is missing', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
const result = await getStarred();
expect(result.songs).toEqual([]);
expect(result.albums).toEqual([]);
expect(result.artists).toEqual([]);
});
it('passes the three array buckets through unchanged', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
starred2: {
song: [{ id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 }],
album: [{ id: 'al-1', name: 'Al', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100 }],
artist: [{ id: 'ar-1', name: 'Artist' }],
},
}),
);
const result = await getStarred();
expect(result.songs).toHaveLength(1);
expect(result.albums).toHaveLength(1);
expect(result.artists).toHaveLength(1);
});
});
describe('getTopSongs', () => {
it('returns an empty array when topSongs.song is absent', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ topSongs: {} }));
expect(await getTopSongs('Nirvana')).toEqual([]);
});
it('passes a song-array through and slices to 5', async () => {
const song = { id: 's', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 };
vi.mocked(axios.get).mockResolvedValue(
okResponse({ topSongs: { song: Array.from({ length: 8 }, (_, i) => ({ ...song, id: `s${i}` })) } }),
);
const songs = await getTopSongs('Nirvana');
expect(songs).toHaveLength(5);
});
it('returns [] on any failure (catch swallows)', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
expect(await getTopSongs('Nirvana')).toEqual([]);
});
});
describe('getArtists', () => {
it('flattens index→artist arrays', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
artists: {
index: [
{ name: 'A', artist: [{ id: 'a1', name: 'Alpha' }] },
{ name: 'B', artist: { id: 'b1', name: 'Beta' } },
],
},
}),
);
const artists = await getArtists();
expect(artists.map(a => a.id)).toEqual(['a1', 'b1']);
});
it('returns empty when index is absent', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ artists: {} }));
expect(await getArtists()).toEqual([]);
});
});
describe('search', () => {
it('returns empty arrays when searchResult3 is empty', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ searchResult3: {} }));
const r = await search('nothing');
expect(r.songs).toEqual([]);
expect(r.albums).toEqual([]);
expect(r.artists).toEqual([]);
});
it('short-circuits to empty arrays for a whitespace-only query (no HTTP)', async () => {
const r = await search(' ');
expect(r.songs).toEqual([]);
expect(r.albums).toEqual([]);
expect(r.artists).toEqual([]);
expect(axios.get).not.toHaveBeenCalled();
});
it('passes array result types through unchanged', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({
searchResult3: {
song: [{ id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 }],
album: [{ id: 'al-1', name: 'Al', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100 }],
artist: [{ id: 'ar-1', name: 'Artist' }],
},
}),
);
const r = await search('q');
expect(r.songs).toHaveLength(1);
expect(r.albums).toHaveLength(1);
expect(r.artists).toHaveLength(1);
});
});
describe('getAlbumInfo2', () => {
it('returns the albumInfo payload', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({ albumInfo: { largeImageUrl: 'https://x', notes: 'about' } }),
);
const info = await getAlbumInfo2('al-1');
expect(info?.largeImageUrl).toBe('https://x');
});
it('returns null on any failure (server error / network)', async () => {
vi.mocked(axios.get).mockResolvedValue(errorResponse('not found'));
expect(await getAlbumInfo2('missing')).toBeNull();
});
});
describe('ping', () => {
it('returns true when the server replies with status=ok', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
expect(await ping()).toBe(true);
});
it('returns false on any failure', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
expect(await ping()).toBe(false);
});
});
describe('pingWithCredentials — explicit URL/credentials path', () => {
it('returns ok=true with type + serverVersion + openSubsonic when present', async () => {
vi.mocked(axios.get).mockResolvedValue(
okResponse({ type: 'navidrome', serverVersion: '0.55', openSubsonic: true }),
);
const r = await pingWithCredentials('https://music.example.com', 'u', 'p');
expect(r.ok).toBe(true);
expect(r.type).toBe('navidrome');
expect(r.serverVersion).toBe('0.55');
expect(r.openSubsonic).toBe(true);
});
it('prepends http:// when the URL lacks a scheme', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
await pingWithCredentials('music.local', 'u', 'p');
const calledUrl = vi.mocked(axios.get).mock.calls[0]?.[0] as string;
expect(calledUrl.startsWith('http://music.local')).toBe(true);
});
it('strips a trailing slash before appending /rest/ping.view', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
await pingWithCredentials('https://music.example.com/', 'u', 'p');
const calledUrl = vi.mocked(axios.get).mock.calls[0]?.[0] as string;
expect(calledUrl).toBe('https://music.example.com/rest/ping.view');
});
it('returns ok=false (no throw) on any failure', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('ECONNREFUSED'));
const r = await pingWithCredentials('https://x.test', 'u', 'p');
expect(r.ok).toBe(false);
expect(r.type).toBeUndefined();
});
it('returns ok=false when the response status is not "ok"', async () => {
vi.mocked(axios.get).mockResolvedValue(errorResponse());
const r = await pingWithCredentials('https://x.test', 'u', 'wrong-pw');
expect(r.ok).toBe(false);
});
it('coerces openSubsonic=true even when the field is omitted (defaults to false)', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
const r = await pingWithCredentials('https://x.test', 'u', 'p');
expect(r.openSubsonic).toBe(false);
});
});