test(api): subsonic.ts async endpoint contracts (F3 follow-up) (#549)

39 new tests targeting the response normalization paths the F3 PR (#544)
deferred. Mocks axios at the module boundary; pins:

- api() helper envelope: unwrap subsonic-response on status=ok, throw
  "Invalid response" without envelope, throw the server message on
  status=failed, throw a generic on failed-without-message, propagate
  network failures.
- song-array vs single-object normalization paths:
  - getMusicDirectory normalizes child (object -> [object]), empty -> [].
  - getMusicIndexes flattens index -> artist arrays (object or array).
  - getMusicFolders coerces numeric ids to strings + defaults name.
  - getRandomSongs pass-through behaviour pinned (no normalization --
    Navidrome always returns the array form).
- collection-shape contracts: getAlbum splits { album, songs } + empty
  fallback when album.song is absent, getStarred returns empty arrays
  on missing starred2 / missing fields / pass-through arrays.
- single endpoint behaviours: getSong null on failure, getTopSongs []
  + slice to 5, getArtists flatten + empty, search whitespace-query
  short-circuit (no HTTP), getAlbumInfo2 null on error, ping
  true/false based on status.
- pingWithCredentials (explicit-URL path): full response (type +
  serverVersion + openSubsonic), http:// prepend when scheme missing,
  trailing-slash strip before /rest/ping.view, ok=false on any
  failure / status=failed, openSubsonic defaults to false when omitted.

subsonic.ts coverage 12.66% -> 31.87% lines (+19pp). The remaining
surface lives in niche endpoints (playlist mutations, statistics
overview/aggregates, internet radio CRUD, cover-art uploads, ratings
prefetch). Not gate-eligible yet; a follow-up could push to ~60% but
diminishing returns relative to other backlog items.

Frontend suite: 412 -> 451 tests (+39).
This commit is contained in:
Frank Stellmacher
2026-05-11 23:41:08 +02:00
committed by GitHub
parent 377675ae94
commit 568f3aeb7d
+446
View File
@@ -0,0 +1,446 @@
/**
* 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 {
getAlbum,
getAlbumInfo2,
getArtists,
getMusicDirectory,
getMusicFolders,
getMusicIndexes,
getRandomSongs,
getSong,
getStarred,
getTopSongs,
pingWithCredentials,
ping,
search,
} from './subsonic';
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);
});
});