1
0

fix: dispatch ADD_START, correlate preview/lookup responses with the current attempt

This commit is contained in:
2026-08-29 22:54:00 +02:00
parent c76d936f1e
commit b9ba4f0ca6
4 changed files with 85 additions and 33 deletions

View File

@@ -46,7 +46,7 @@ function stateOf(phase: ScanState['phase']): ScanState {
{ type: 'DETECT', code: '5021592210629' },
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
{ type: 'SELECT', candidate },
{ type: 'PREVIEW', preview },
{ type: 'PREVIEW', preview, candidateId: 1001 },
{ type: 'ADD_START' },
{ type: 'ADDED', item },
]
@@ -83,10 +83,37 @@ describe('scanReducer', () => {
})
it('ERROR maps kinds', () => {
const next = scanReducer(stateOf('looking'), { type: 'ERROR', kind: 'no_discogs_token', code: '123' })
const next = scanReducer(stateOf('looking'), {
type: 'ERROR',
kind: 'no_discogs_token',
code: '123',
source: 'lookup',
})
expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' })
})
it('ignores a stale preview for a different candidate', () => {
const confirm = stateOf('confirm')
const stale = scanReducer(confirm, { type: 'PREVIEW', preview, candidateId: 999 })
expect(stale).toBe(confirm)
})
it('ignores a preview-source error in the looking phase', () => {
const looking = stateOf('looking')
const next = scanReducer(looking, {
type: 'ERROR', kind: 'server', code: null, source: 'preview', candidateId: 1001,
})
expect(next).toBe(looking)
})
it('ignores a lookup-source error after the user moved past looking', () => {
const candidates = stateOf('candidates')
const next = scanReducer(candidates, {
type: 'ERROR', kind: 'server', code: '123', source: 'lookup',
})
expect(next).toBe(candidates)
})
it('SELECT from candidates → confirm with candidate, no preview yet', () => {
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
expect(next.phase).toBe('confirm')
@@ -99,7 +126,7 @@ describe('scanReducer', () => {
})
it('PREVIEW fills the confirm phase', () => {
const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview })
const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
if (next.phase === 'confirm') expect(next.preview).toBe(preview)
else throw new Error('expected confirm')
})
@@ -114,7 +141,7 @@ describe('scanReducer', () => {
})
it('ADD_START → ADDED', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview })
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
const added = scanReducer(starting, { type: 'ADDED', item })
@@ -122,7 +149,7 @@ describe('scanReducer', () => {
})
it('ADD_ERROR stores the message without leaving confirm', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview })
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
if (next.phase === 'confirm') {

View File

@@ -3,7 +3,7 @@ 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, ReleasePreview } from '../src/types.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
vi.mock('../src/components/Scanner.js', () => ({
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
@@ -48,6 +48,25 @@ const preview: ReleasePreview = {
}
// 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)
}
@@ -70,26 +89,7 @@ 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({
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',
}) as never
)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk(addedItem) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
@@ -158,4 +158,23 @@ describe('ScanPage flow', () => {
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)
})
})