feat: background subsonic library sync with per-user state
This commit is contained in:
157
server/test/library.test.ts
Normal file
157
server/test/library.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user