fix: reset-to-auto in both rip branches, visible mutation failures, distinct invalid-token error
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user