1
0
Files
record-shop/server/test/library.test.ts

158 lines
5.5 KiB
TypeScript
Raw Normal View History

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()
})
})