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

266 lines
9.8 KiB
TypeScript
Raw Normal View History

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('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
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: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
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',
webUrl: 'http://navidrome.local/app/#/album/alb-9',
})
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()
})
})