1
0

fix: reset-to-auto in both rip branches, visible mutation failures, distinct invalid-token error

This commit is contained in:
2026-09-03 19:24:57 +02:00
parent f2fff1d7be
commit 21a4c69972
6 changed files with 101 additions and 24 deletions

View File

@@ -10,6 +10,7 @@ function toErrorKind(err: unknown): ScanErrorKind {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.code === 'not_found') return 'not_found' if (err.code === 'not_found') return 'not_found'
if (err.code === 'no_discogs_token') return 'no_discogs_token' if (err.code === 'no_discogs_token') return 'no_discogs_token'
if (err.code === 'discogs_auth') return 'discogs_auth'
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited' if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
} }
return 'server' return 'server'
@@ -179,11 +180,16 @@ export default function AddPage() {
{state.phase === 'error' && ( {state.phase === 'error' && (
<div className="space-y-3 py-8 text-center"> <div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium"> <p className="text-lg font-medium">
{state.kind === 'not_found' ? 'Nothing found' : 'Search failed'} {state.kind === 'not_found'
? 'Nothing found'
: state.kind === 'discogs_auth'
? 'Discogs rejected your token'
: 'Search failed'}
</p> </p>
<p className="text-sm text-neutral-400"> <p className="text-sm text-neutral-400">
{state.kind === 'not_found' && 'Try different spelling, or add the year.'} {state.kind === 'not_found' && 'Try different spelling, or add the year.'}
{state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'} {state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'}
{state.kind === 'discogs_auth' && 'Check your Discogs token in Settings.'}
{state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'} {state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'}
</p> </p>
<button <button

View File

@@ -14,8 +14,10 @@ export default function ItemPage() {
const [albums, setAlbums] = useState<DigitalAlbum[] | null>(null) const [albums, setAlbums] = useState<DigitalAlbum[] | null>(null)
const [pickedAlbum, setPickedAlbum] = useState<number | null>(null) const [pickedAlbum, setPickedAlbum] = useState<number | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false) const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setMutationError(null)
void api void api
.getItem(Number(id)) .getItem(Number(id))
.then(setItem) .then(setItem)
@@ -34,12 +36,20 @@ export default function ItemPage() {
function applyMatch(albumId: number | null) { function applyMatch(albumId: number | null) {
if (!item) return if (!item) return
void api.setMatch(item.id, albumId).then(setItem) void api
.setMatch(item.id, albumId)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
} }
function remove() { function remove() {
if (!item) return if (!item) return
void api.deleteItem(item.id).then(() => navigate('/library')) void api.deleteItem(item.id).then(() => navigate('/library')).catch(() =>
setMutationError("That didn't work — check your connection and try again.")
)
} }
if (error) return <p className="py-8 text-center text-sm text-red-400">Item not found.</p> if (error) return <p className="py-8 text-center text-sm text-red-400">Item not found.</p>
@@ -75,38 +85,65 @@ export default function ItemPage() {
{item.ripOverride !== null && ' (manually set)'} {item.ripOverride !== null && ' (manually set)'}
</p> </p>
) : ( ) : (
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">Not ripped yet</p> <p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
Not ripped yet
{item.ripOverride !== null && ' (manually set)'}
</p>
)} )}
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{item.ripStatus === 'ripped' ? ( {item.ripStatus === 'ripped' ? (
<> <button
<button type="button"
type="button" onClick={() =>
onClick={() => void api.setRip(item.id, false).then(setItem)} void api
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300" .setRip(item.id, false)
> .then((updated) => {
Mark not ripped setItem(updated)
</button> setMutationError(null)
{item.ripOverride !== null && ( })
<button .catch(() => setMutationError("That didn't work — check your connection and try again."))
type="button" }
onClick={() => void api.setRip(item.id, null).then(setItem)} className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300" >
> Mark not ripped
Reset to auto </button>
</button>
)}
</>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => void api.setRip(item.id, true).then(setItem)} onClick={() =>
void api
.setRip(item.id, true)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300" className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
> >
Mark ripped Mark ripped
</button> </button>
)} )}
{item.ripOverride !== null && (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, null)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Reset to auto
</button>
)}
</div> </div>
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3"> <section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">

View File

@@ -11,6 +11,7 @@ function toErrorKind(err: unknown): ScanErrorKind {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.code === 'not_found') return 'not_found' if (err.code === 'not_found') return 'not_found'
if (err.code === 'no_discogs_token') return 'no_discogs_token' if (err.code === 'no_discogs_token') return 'no_discogs_token'
if (err.code === 'discogs_auth') return 'discogs_auth'
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited' if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
} }
return 'server' return 'server'
@@ -185,6 +186,16 @@ export default function ScanPage() {
</div> </div>
)} )}
{state.phase === 'error' && state.kind === 'discogs_auth' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Discogs rejected your token</p>
<p className="text-sm text-neutral-400">Check your Discogs token in Settings.</p>
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
Settings
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'server' && ( {state.phase === 'error' && state.kind === 'server' && (
<div className="space-y-3 py-8 text-center"> <div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Lookup failed</p> <p className="text-lg font-medium">Lookup failed</p>

View File

@@ -1,6 +1,6 @@
import type { Candidate, Item, ReleasePreview } from '../types.js' import type { Candidate, Item, ReleasePreview } from '../types.js'
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'rate_limited' | 'server' export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'discogs_auth' | 'rate_limited' | 'server'
export type ScanState = export type ScanState =
| { phase: 'scan' } | { phase: 'scan' }

View File

@@ -127,4 +127,19 @@ describe('ItemPage', () => {
const linkBtn = await screen.findByRole('button', { name: /^link$/i }) const linkBtn = await screen.findByRole('button', { name: /^link$/i })
expect(linkBtn).toHaveProperty('disabled', true) expect(linkBtn).toHaveProperty('disabled', true)
}) })
it('shows Reset to auto for a manual not-ripped override', async () => {
vi.mocked(api.getItem).mockResolvedValue({ ...item, ripOverride: false, ripStatus: 'not_ripped' } as never)
renderItem()
await waitFor(() => expect(screen.getByText(/not ripped yet \(manually set\)/i)).toBeTruthy())
expect(screen.getByRole('button', { name: /reset to auto/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /mark ripped/i })).toBeTruthy()
})
it('shows an error when a rip toggle fails', async () => {
vi.mocked(api.setRip).mockRejectedValue(new TypeError('fetch failed'))
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
})
}) })

View File

@@ -177,4 +177,12 @@ describe('ScanPage flow', () => {
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy()) await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledTimes(1) 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')
})
}) })