251 lines
9.4 KiB
TypeScript
251 lines
9.4 KiB
TypeScript
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()
|
|
})
|
|
})
|