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 }) => ( ), })) vi.mock('../src/api.js', async (importOriginal) => { const actual = await importOriginal() 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( ) } 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') }) })