feat: collection CRUD with artwork caching, rip override and match links
This commit is contained in:
215
server/test/collection.test.ts
Normal file
215
server/test/collection.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user