import { describe, it, expect } from 'vitest' import { SubsonicClient, SubsonicError } from '../src/subsonic.js' function subsonicResponse(body: object, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }) } function albumPage(count: number, offset: number) { return { 'subsonic-response': { status: 'ok', albumList2: { album: Array.from({ length: count }, (_, i) => ({ id: offset + i + 1, name: `Album ${offset + i + 1}`, artist: `Artist ${Math.floor((offset + i) / 10)}`, })), }, }, } } describe('SubsonicClient', () => { it('ping sends auth params and succeeds', async () => { const seen: string[] = [] const c = new SubsonicClient({ url: 'http://navidrome.local', username: 'sam', password: 'pass', fetchImpl: (async (input: any) => { seen.push(String(input)) return subsonicResponse({ 'subsonic-response': { status: 'ok' } }) }) as typeof fetch, }) await c.ping() expect(seen[0]!).toContain('/rest/ping') expect(seen[0]!).toContain('u=sam') expect(seen[0]!).toContain('v=1.16.1') expect(seen[0]!).toContain('c=record-shop') expect(seen[0]!).toContain('f=json') expect(seen[0]!).toMatch(/t=[0-9a-f]{32}/) }) it('ping raises auth error on failed status with code 40', async () => { const c = new SubsonicClient({ url: 'http://x', username: 'sam', password: 'bad', fetchImpl: (async () => subsonicResponse({ 'subsonic-response': { status: 'failed', error: { code: 40, message: 'Wrong username or password.' }, }, })) as typeof fetch, }) const err = await c.ping().catch((e) => e) expect(err).toBeInstanceOf(SubsonicError) expect((err as SubsonicError).code).toBe('auth') }) it('getAllAlbums paginates until a short page', async () => { const seen: string[] = [] const c = new SubsonicClient({ url: 'http://navidrome.local', username: 'sam', password: 'pass', fetchImpl: (async (input: any) => { const url = new URL(String(input)) seen.push(url.searchParams.get('offset') ?? '') const offset = Number(url.searchParams.get('offset') ?? 0) // first page: 500 albums, second: 3, third never requested return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) }) as typeof fetch, }) const albums = await c.getAllAlbums() expect(seen).toEqual(['0', '500']) expect(albums).toHaveLength(503) expect(albums[0]!).toEqual({ id: '1', title: 'Album 1', artist: 'Artist 0' }) }) it('getAllAlbums reports progress', async () => { const c = new SubsonicClient({ url: 'http://x', username: 'sam', password: 'pass', fetchImpl: (async (input: any) => { const offset = Number(new URL(String(input)).searchParams.get('offset') ?? 0) return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) }) as typeof fetch, }) const progress: number[] = [] await c.getAllAlbums((_albums, done) => progress.push(done)) expect(progress[progress.length - 1]!).toBe(503) }) it('getAllAlbums skips null album entries', async () => { const c = new SubsonicClient({ url: 'http://x', username: 'sam', password: 'pass', fetchImpl: (async () => subsonicResponse({ 'subsonic-response': { status: 'ok', albumList2: { album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }, null, { id: 2, name: 'Album 2', artist: 'Artist 0' }], }, }, })) as typeof fetch, }) const albums = await c.getAllAlbums() expect(albums).toEqual([ { id: '1', title: 'Album 1', artist: 'Artist 0' }, { id: '2', title: 'Album 2', artist: 'Artist 0' }, ]) }) it('url() builds a raw endpoint URL with auth params', () => { const c = new SubsonicClient({ url: 'http://navidrome.local', username: 'sam', password: 'pass', fetchImpl: (async () => subsonicResponse({})) as typeof fetch, }) const u = new URL(c.url('stream', { id: 'song-9' })) expect(u.pathname).toBe('/rest/stream') expect(u.searchParams.get('id')).toBe('song-9') expect(u.searchParams.get('u')).toBe('sam') expect(u.searchParams.get('f')).toBe('json') }) it('getAlbum returns ordered tracks', async () => { const c = new SubsonicClient({ url: 'http://x', username: 'sam', password: 'pass', fetchImpl: (async () => subsonicResponse({ 'subsonic-response': { status: 'ok', album: { id: 'alb-1', name: 'Motion', artist: 'The Cinematic Orchestra', song: [ { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, { id: 's1', title: 'Overture', duration: 200, track: 1 }, ], }, }, })) as typeof fetch, }) const album = await c.getAlbum('alb-1') expect(album).toEqual({ id: 'alb-1', title: 'Motion', artist: 'The Cinematic Orchestra', tracks: [ { id: 's1', title: 'Overture', duration: 200, track: 1 }, { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, ], }) }) it('getRecentAlbums returns the recent list raw', async () => { const c = new SubsonicClient({ url: 'http://x', username: 'sam', password: 'pass', fetchImpl: (async () => subsonicResponse({ 'subsonic-response': { status: 'ok', albumList2: { album: [ { id: 'a1', name: 'Motion', artist: 'TCO', played: '2026-09-01T10:00:00Z' }, { id: 'a2', name: 'Blue Lines', artist: 'Massive Attack' }, ], }, }, })) as typeof fetch, }) const recent = await c.getRecentAlbums(500) expect(recent).toEqual([ { id: 'a1', title: 'Motion', artist: 'TCO', playedAt: '2026-09-01T10:00:00Z' }, { id: 'a2', title: 'Blue Lines', artist: 'Massive Attack', playedAt: null }, ]) }) })