import { describe, it, expect } from 'vitest' import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js' 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)}`, })), }, }, } } function subsonicStub(): typeof fetch { return (async (input: any) => { const url = new URL(String(input)) if (url.pathname.endsWith('/rest/ping')) { return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { status: 200, headers: { 'content-type': 'application/json' }, }) } if (url.pathname.endsWith('/rest/getAlbumList2')) { const offset = Number(url.searchParams.get('offset') ?? 0) return new Response( JSON.stringify(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)), { status: 200, headers: { 'content-type': 'application/json' } } ) } return new Response('nope', { status: 404 }) }) as typeof fetch } async function appWithSubsonic() { const app = await buildTestApp(subsonicStub()) const cookie = await setupAdmin(app) await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam', subsonicPassword: 'pass', }, }) return { app, cookie } } async function waitForDone(app: any, cookie: string, timeoutMs = 2000) { const start = Date.now() while (Date.now() - start < timeoutMs) { const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json() if (state.status !== 'running') return state await new Promise((r) => setTimeout(r, 10)) } throw new Error('sync did not finish in time') } describe('library sync', () => { it('sync paginates subsonic and caches albums', async () => { const { app, cookie } = await appWithSubsonic() const start = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) expect(start.statusCode).toBe(202) const state = await waitForDone(app, cookie) expect(state.status).toBe('done') expect(state.albums).toBe(503) expect(state.lastSyncedAt).toBeTruthy() const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album 7', ...auth(cookie) }) expect(albums.json().albums.length).toBeGreaterThan(0) // idempotent re-sync: count stays 503 (upsert, no duplicates) await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) const second = await waitForDone(app, cookie) expect(second.albums).toBe(503) await app.close() }) it('returns 409 without subsonic config', async () => { const app = await buildTestApp() const cookie = await setupAdmin(app) const res = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) expect(res.statusCode).toBe(409) expect(res.json()).toEqual({ error: 'no_subsonic_config' }) await app.close() }) it('reports error state when album fetch fails after valid ping', async () => { // ping succeeds (settings validation passes) but getAlbumList2 fails (sync errors) const fetcher = (async (input: any) => { if (String(input).includes('/rest/ping')) { return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { status: 200, headers: { 'content-type': 'application/json' }, }) } throw new TypeError('fetch failed') }) as unknown as typeof fetch const app = await buildTestApp(fetcher) const cookie = await setupAdmin(app) await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { subsonicUrl: 'http://flaky.local', subsonicUsername: 'sam', subsonicPassword: 'pass', }, }) await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) const state = await waitForDone(app, cookie) expect(state.status).toBe('error') expect(state.error).toBeTruthy() await app.close() }) it('albums search is scoped per user', async () => { const { app, cookie } = await appWithSubsonic() await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) await waitForDone(app, cookie) const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123') const empty = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album', ...auth(bobCookie) }) expect(empty.json().albums).toHaveLength(0) await app.close() }) it('test-seed route inserts albums directly (used by lookup/collection tests)', async () => { const app = await buildTestApp() const cookie = await setupAdmin(app) const res = await app.inject({ method: 'POST', url: '/api/library/albums/test-seed', ...auth(cookie), payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, }) expect(res.statusCode).toBe(200) expect(res.json().inserted).toBe(1) await app.close() }) it('saving subsonic settings triggers a first sync', async () => { const { app, cookie } = await appWithSubsonic() const state = await waitForDone(app, cookie) expect(state.status).toBe('done') expect(state.albums).toBe(503) await app.close() }) })