1
0
Files
record-shop/web/test/scanPage.test.tsx

189 lines
7.1 KiB
TypeScript
Raw Permalink Normal View History

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import ScanPage from '../src/pages/ScanPage.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
vi.mock('../src/components/Scanner.js', () => ({
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
<button type="button" onClick={() => onDetect('5021592210629')}>
fake-scan
</button>
),
}))
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return {
...actual,
api: {
...actual.api,
lookupBarcode: vi.fn(),
getReleasePreview: vi.fn(),
addToCollection: vi.fn(),
},
}
})
import { api, ApiError } from '../src/api.js'
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: null,
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
// The api module is mocked directly, so mocks resolve with parsed bodies.
const addedItem: Item = {
id: 1,
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: [],
labels: ['Ninja Tune'],
tracklist: [],
catno: 'ZENCD012',
country: 'UK',
artworkUrl: null,
barcodes: ['5021592210629'],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
}
function jsonOk(body: unknown) {
return Promise.resolve(body)
}
beforeEach(() => {
vi.mocked(api.lookupBarcode).mockReset()
vi.mocked(api.getReleasePreview).mockReset()
vi.mocked(api.addToCollection).mockReset()
})
function renderScan() {
return render(
<MemoryRouter initialEntries={['/scan']}>
<ScanPage />
</MemoryRouter>
)
}
describe('ScanPage flow', () => {
it('scan → candidates → confirm → added', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk(addedItem) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
expect(screen.getByText('Overture')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledWith({
releaseId: 1001,
barcode: '5021592210629',
})
})
it('shows not-found guidance with a manual-search escape hatch', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /search manually/i }).getAttribute('href')).toBe(
'/add?q=5021592210629'
)
})
it('sends the picked match candidate for ambiguous rip matches', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(
jsonOk({
...preview,
ripMatch: 'ambiguous',
matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }],
}) as never
)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy())
await userEvent.click(screen.getByRole('radio', { name: /somebody else/i }))
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({
releaseId: 1001,
barcode: '5021592210629',
matchAlbumId: 77,
}))
})
it('shows a duplicate warning from the preview', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy())
})
it('links to settings when no discogs token is configured', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(409, 'no_discogs_token'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
})
it('does not add twice when the button is clicked rapidly', async () => {
let resolveAdd: (v: unknown) => void = () => {}
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockImplementation(
() => new Promise((resolve) => { resolveAdd = resolve }) as never
)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
const addBtn = await screen.findByRole('button', { name: /add to collection/i })
await userEvent.click(addBtn)
// adding=true → button is disabled ('Adding…'); a second rapid click must not re-fire the request
await userEvent.click(addBtn).catch(() => {})
resolveAdd(addedItem)
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledTimes(1)
})
it('distinguishes an invalid discogs token with a settings link', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(502, 'discogs_auth'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/rejected your token/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
})
})