import { describe, it, expect } from 'vitest' import { buildTestApp, setupAdmin, auth, getCookie } from './helpers.js' import { discogsReleaseFixture } from './fixtures.js' function discogsStub(): typeof fetch { return (async (input: any) => { const url = String(input) if (url.includes('/releases/1001')) { return new Response(JSON.stringify(discogsReleaseFixture), { status: 200, headers: { 'content-type': 'application/json' }, }) } if (url.includes('img.discogs.com')) { return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), { status: 200, headers: { 'content-type': 'image/jpeg' }, }) } return new Response('nope', { status: 404 }) }) as typeof fetch } async function appWithToken() { const app = await buildTestApp(discogsStub()) const cookie = await setupAdmin(app) await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 'testtoken' }, }) return { app, cookie } } describe('collection routes', () => { it('adds a release from discogs, returns item with artwork and rip status', async () => { const { app, cookie } = await appWithToken() const res = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001, barcode: '5021592210629' }, }) expect(res.statusCode).toBe(200) const item = res.json() expect(item).toMatchObject({ discogsReleaseId: 1001, title: 'Motion', artist: 'The Cinematic Orchestra', year: 1999, formats: ['CD'], labels: ['Ninja Tune'], catno: 'ZENCD012', barcodes: ['5021592210629'], ripOverride: null, ripStatus: 'not_ripped', }) expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/) await app.close() }) it('rejects duplicate add with 409', async () => { const { app, cookie } = await appWithToken() await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) const again = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 }, }) expect(again.statusCode).toBe(409) expect(again.json()).toEqual({ error: 'duplicate' }) await app.close() }) it('lists items with counts and filters (format, ripped, q)', async () => { const { app, cookie } = await appWithToken() await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) }) expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 }) expect(all.json().items).toHaveLength(1) const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) }) expect(cd.json().items).toHaveLength(1) const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) }) expect(vinyl.json().items).toHaveLength(0) const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) }) expect(ripped.json().items).toHaveLength(0) const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) }) expect(notRipped.json().items).toHaveLength(1) const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) }) expect(q.json().items).toHaveLength(1) const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) }) expect(qMiss.json().items).toHaveLength(0) await app.close() }) it('detail, rip override, match link, re-match search, delete', async () => { const { app, cookie } = await appWithToken() const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 }, }) const id = added.json().id as number const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) expect(detail.statusCode).toBe(200) expect(detail.json().tracklist).toEqual([ { position: '1', title: 'Overture' }, { position: '2', title: 'Theme de Yoyo' }, ]) // manual rip override const rip = await app.inject({ method: 'PATCH', url: `/api/collection/${id}/rip`, ...auth(cookie), payload: { ripped: true }, }) expect(rip.json().ripOverride).toBe(true) expect(rip.json().ripStatus).toBe('ripped') const clear = await app.inject({ method: 'PATCH', url: `/api/collection/${id}/rip`, ...auth(cookie), payload: { ripped: null }, }) expect(clear.json().ripOverride).toBeNull() expect(clear.json().ripStatus).toBe('not_ripped') // match link (simulates confirmed ambiguous match) await app.inject({ method: 'POST', url: '/api/library/albums/test-seed', ...auth(cookie), payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] }, }) const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) }) const albumId = albums.json().albums[0].id as number const linked = await app.inject({ method: 'POST', url: `/api/collection/${id}/match`, ...auth(cookie), payload: { albumId }, }) expect(linked.json().ripStatus).toBe('ripped') // clear link const unlinked = await app.inject({ method: 'POST', url: `/api/collection/${id}/match`, ...auth(cookie), payload: { albumId: null }, }) expect(unlinked.json().ripStatus).toBe('not_ripped') const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) }) expect(del.statusCode).toBe(200) const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) expect(after.statusCode).toBe(404) await app.close() }) it('rejects match to another users album', async () => { const { app, cookie } = await appWithToken() const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 }, }) const id = added.json().id as number await app.inject({ method: 'POST', url: '/api/library/albums/test-seed', ...auth(cookie), payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] }, }) // album id 99 does not exist for this user const res = await app.inject({ method: 'POST', url: `/api/collection/${id}/match`, ...auth(cookie), payload: { albumId: 99 }, }) expect(res.statusCode).toBe(404) await app.close() }) it('second user cannot see first users items', async () => { const { app, cookie } = await appWithToken() await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) await app.inject({ method: 'POST', url: '/api/users', ...auth(cookie), payload: { username: 'bob', password: 'bobpass123' }, }) const bobLogin = await app.inject({ method: 'POST', url: '/api/login', payload: { username: 'bob', password: 'bobpass123' }, }) const bobCookie = getCookie(bobLogin) const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) }) expect(list.json().items).toHaveLength(0) await app.close() }) it('detail includes matchedAlbum (null when unmatched)', async () => { const { app, cookie } = await appWithToken() const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) const id = added.json().id as number const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) expect(detail.json().matchedAlbum).toBeNull() await app.inject({ method: 'POST', url: '/api/library/albums/test-seed', ...auth(cookie), payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, }) const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null }) await app.close() }) // KNOWN-RED handoff: the loan route arrives in Task 5 — this test turns // green then. Everything else in this file must pass now. it('list supports onLoan=true/false filter', async () => { const { app, cookie } = await appWithToken() const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) const id = added.json().id as number await app.inject({ method: 'POST', url: `/api/collection/${id}/loan`, ...auth(cookie), payload: { borrower: 'Bob' } }) const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) }) expect(all.json().items).toHaveLength(1) const onLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=true', ...auth(cookie) }) expect(onLoan.json().items).toHaveLength(1) const notOnLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=false', ...auth(cookie) }) expect(notOnLoan.json().items).toHaveLength(0) await app.close() }) })