mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): bound provider ids and surface why a save was rejected
A provider identifier parsed out of an OPF was passed on verbatim, so a Calibre `opf:scheme="AMAZON"` holding a product URL reached a varchar(20) column as a 36-character value. It could not be stored, and the next manual save of that book was rejected for a field the user never typed into. Drop an identifier that cannot fit its column rather than truncating it: an overflowing value is not a long identifier, it is a different kind of value, and its first 20 characters are not an ASIN either. Enforce the same bound where every extractor converges, so no parser can fail a metadata write with a Postgres 22001. Keep the server's description of a rejected save instead of discarding the response body. Only that text names the field at fault, so the editor showed a bare "HTTP 400" and left the reason in the network tab. It stays untranslated, so it is preferred over the catalog message rather than replacing it, and the failure is carried as a structured value so the component still translates everything else. Tie the DTO limits to the column widths they were copied from, and guard the pair with a test, since nothing connected them when they drifted apart. Stand Node's built-in web storage down in the client test run. Node 26 defines localStorage on globalThis, jsdom shares that object, and its getter reads as undefined without --localstorage-file, which failed 229 tests across 28 unrelated suites on CI. Closes #1015
This commit is contained in:
@@ -252,7 +252,18 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const combinedError = computed(() => lockError.value ?? error.value)
|
||||
// The server names the field it rejected ("amazonId must be shorter than..."), which is the only
|
||||
// text that tells the user what to fix. It is untranslated, so it is preferred over the generic
|
||||
// catalog message rather than replacing it.
|
||||
const saveErrorMessage = computed(() => {
|
||||
const failure = error.value
|
||||
if (!failure) return null
|
||||
if (failure.detail) return failure.detail
|
||||
if (failure.status !== null) return t('book.detail.editMetadata.saveFailedWithStatus', { status: failure.status })
|
||||
return t('book.detail.editMetadata.saveFailed')
|
||||
})
|
||||
|
||||
const combinedError = computed(() => lockError.value ?? saveErrorMessage.value)
|
||||
const hasLockedFields = computed(() => lockedFields.value.length > 0)
|
||||
const hasPendingChanges = computed(() => isDirty.value || locksDirty.value)
|
||||
const isSeriesLocked = computed(() => isLocked('seriesName') || isLocked('seriesIndex'))
|
||||
|
||||
@@ -409,15 +409,81 @@ describe('useMetadataEditor', () => {
|
||||
expect(isDirty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('captures an error message when the save request fails', async () => {
|
||||
// Regression: a rejected save used to surface as a bare "HTTP 400", so the only text naming the
|
||||
// field the server refused was left in the response body. See issue #1015.
|
||||
it('keeps the server validation detail when a save is rejected', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockResolvedValue({ ok: false, status: 400 })
|
||||
apiMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({
|
||||
statusCode: 400,
|
||||
message: ['amazonId must be shorter than or equal to 20 characters'],
|
||||
error: 'Bad Request',
|
||||
}),
|
||||
})
|
||||
|
||||
const { load, save, error } = useMetadataEditor()
|
||||
load(book)
|
||||
const result = await save(book.id, [])
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(error.value).toBe('HTTP 400')
|
||||
expect(error.value).toEqual({ detail: 'amazonId must be shorter than or equal to 20 characters', status: 400 })
|
||||
})
|
||||
|
||||
it('reports the status alone when the failure body carries no message', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({ statusCode: 500 }) })
|
||||
|
||||
const { load, save, error } = useMetadataEditor()
|
||||
load(book)
|
||||
|
||||
expect(await save(book.id, [])).toBeNull()
|
||||
expect(error.value).toEqual({ detail: null, status: 500 })
|
||||
})
|
||||
|
||||
it('reports neither detail nor status when the request never reached the server', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockRejectedValue(new Error('Failed to fetch'))
|
||||
|
||||
const { load, save, error } = useMetadataEditor()
|
||||
load(book)
|
||||
|
||||
expect(await save(book.id, [])).toBeNull()
|
||||
expect(error.value).toEqual({ detail: null, status: null })
|
||||
})
|
||||
|
||||
it('does not advance the snapshot when a save is rejected, so a retry resends the change', async () => {
|
||||
const book = makeBook({ title: 'Original Title' })
|
||||
apiMock.mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: 'nope' }) })
|
||||
|
||||
const { form, load, save, isDirty } = useMetadataEditor()
|
||||
load(book)
|
||||
form.title = 'Changed Title'
|
||||
await save(book.id, [])
|
||||
|
||||
expect(isDirty.value).toBe(true)
|
||||
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, title: 'Changed Title' }) })
|
||||
await save(book.id, [])
|
||||
|
||||
const [, retry] = apiMock.mock.calls[1] as [string, RequestInit]
|
||||
expect(JSON.parse(String(retry.body)).metadata).toEqual({ title: 'Changed Title' })
|
||||
expect(isDirty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a previous failure once a save succeeds', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: 'nope' }) })
|
||||
|
||||
const { load, save, error } = useMetadataEditor()
|
||||
load(book)
|
||||
await save(book.id, [])
|
||||
expect(error.value).not.toBeNull()
|
||||
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => book })
|
||||
await save(book.id, [])
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { readApiErrorDetail } from '@/lib/api-error'
|
||||
import {
|
||||
FORMAT_TO_GROUP,
|
||||
type BookCommunityRating,
|
||||
@@ -110,9 +111,21 @@ function changedCustomMetadataPayload(
|
||||
.map((field) => ({ fieldId: field.fieldId, value: field.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed save, kept structured rather than pre-rendered: `detail` is the server's own English
|
||||
* description of what it rejected, which no catalog can translate, while everything else has to
|
||||
* resolve to a translated message in the component.
|
||||
*/
|
||||
export type MetadataSaveFailure = {
|
||||
/** Server-supplied description, or null when the response carried none. */
|
||||
detail: string | null
|
||||
/** HTTP status, or null when the request failed before producing a response. */
|
||||
status: number | null
|
||||
}
|
||||
|
||||
export function useMetadataEditor() {
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const error = ref<MetadataSaveFailure | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
title: null as string | null,
|
||||
@@ -308,12 +321,15 @@ export function useMetadataEditor() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata, lockedFields }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
if (!res.ok) {
|
||||
error.value = { detail: await readApiErrorDetail(res), status: res.status }
|
||||
return null
|
||||
}
|
||||
const updated = normalizeSaveResult((await res.json()) as BookDetail | BookMetadataSaveResult)
|
||||
snapshot.value = submittedSnapshot
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to save'
|
||||
} catch {
|
||||
error.value = { detail: null, status: null }
|
||||
return null
|
||||
} finally {
|
||||
saving.value = false
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readApiErrorDetail } from '../api-error'
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return { json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
describe('readApiErrorDetail', () => {
|
||||
it('joins the constraint array a ValidationPipe rejection returns', async () => {
|
||||
const detail = await readApiErrorDetail(
|
||||
jsonResponse({
|
||||
statusCode: 400,
|
||||
message: ['amazonId must be shorter than or equal to 20 characters', 'goodreadsId must be a string'],
|
||||
error: 'Bad Request',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(detail).toBe('amazonId must be shorter than or equal to 20 characters, goodreadsId must be a string')
|
||||
})
|
||||
|
||||
it('returns the plain string message a thrown HttpException produces', async () => {
|
||||
expect(await readApiErrorDetail(jsonResponse({ statusCode: 404, message: 'Book 7 not found' }))).toBe('Book 7 not found')
|
||||
})
|
||||
|
||||
it('returns null when the body carries no usable message', async () => {
|
||||
expect(await readApiErrorDetail(jsonResponse({ statusCode: 500 }))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse({ message: '' }))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse({ message: ' ' }))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse({ message: [] }))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse({ message: 42 }))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse(null))).toBeNull()
|
||||
expect(await readApiErrorDetail(jsonResponse('plain text'))).toBeNull()
|
||||
})
|
||||
|
||||
it('drops non-string entries rather than rendering them as undefined', async () => {
|
||||
expect(await readApiErrorDetail(jsonResponse({ message: ['title must be a string', null, 7] }))).toBe('title must be a string')
|
||||
})
|
||||
|
||||
it('returns null for a body that is not JSON at all', async () => {
|
||||
const html = {
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <')
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
expect(await readApiErrorDetail(html)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the response cannot be read as JSON at all', async () => {
|
||||
expect(await readApiErrorDetail({} as Response)).toBeNull()
|
||||
})
|
||||
|
||||
it('caps a long message so a rejected bulk payload cannot flood the layout', async () => {
|
||||
const detail = await readApiErrorDetail(jsonResponse({ message: Array.from({ length: 40 }, (_, i) => `field${i} must be a string`) }))
|
||||
|
||||
expect(detail).toHaveLength(303)
|
||||
expect(detail?.endsWith('...')).toBe(true)
|
||||
expect(detail?.startsWith('field0 must be a string')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a message that fits the cap untouched', async () => {
|
||||
const message = 'x'.repeat(300)
|
||||
|
||||
expect(await readApiErrorDetail(jsonResponse({ message }))).toBe(message)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A failed response carries the only description of what was actually wrong: the `message` the
|
||||
* global exception filter serialized. Discarding it leaves the user with a bare status code and no
|
||||
* way to tell which field the server rejected.
|
||||
*/
|
||||
|
||||
/** `forbidNonWhitelisted` can name every rejected property at once, and this renders inline. */
|
||||
const MAX_DETAIL_LENGTH = 300
|
||||
|
||||
/**
|
||||
* Reads the server's own description of a failure. Returns null when the body carried none, so
|
||||
* callers can fall back to translated copy instead of showing an untranslated status code.
|
||||
*
|
||||
* Nest sends a plain string for thrown `HttpException`s and an array of constraint messages for
|
||||
* `ValidationPipe` rejections; both shapes are flattened here.
|
||||
*/
|
||||
export async function readApiErrorDetail(response: Response): Promise<string | null> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const message: unknown = (body as { message?: unknown }).message
|
||||
|
||||
let detail = ''
|
||||
if (typeof message === 'string') {
|
||||
detail = message
|
||||
} else if (Array.isArray(message)) {
|
||||
detail = message.filter((entry): entry is string => typeof entry === 'string').join(', ')
|
||||
}
|
||||
|
||||
detail = detail.trim()
|
||||
if (!detail) return null
|
||||
return detail.length > MAX_DETAIL_LENGTH ? `${detail.slice(0, MAX_DETAIL_LENGTH)}...` : detail
|
||||
}
|
||||
@@ -3908,6 +3908,8 @@
|
||||
"savedButWriteFailed": "Metadata saved, but file write failed",
|
||||
"savedWriteSkippedReason": "Metadata saved, file write skipped: {reason}",
|
||||
"savedWriteSkipped": "Metadata saved, file write skipped",
|
||||
"saveFailed": "Could not save the changes",
|
||||
"saveFailedWithStatus": "Could not save the changes (HTTP {status})",
|
||||
"loadFromFileFailed": "Failed to load metadata from file",
|
||||
"loadedFieldsFromFile": "{count, plural, =0 {Loaded no fields from file} one {Loaded one field from file} other {Loaded # fields from file}}",
|
||||
"noMetadataInFile": "No metadata found in file",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { config } from '@vue/test-utils'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
// Fails loudly if `--no-experimental-webstorage` ever stops reaching the workers (see the execArgv
|
||||
// note in vitest.config.ts). Node's own Web Storage globals shadow jsdom's, and the resulting
|
||||
// `localStorage` reads as undefined, which otherwise surfaces as hundreds of unrelated "Cannot read
|
||||
// properties of undefined" failures rather than as the configuration problem it is.
|
||||
if (typeof window !== 'undefined' && !globalThis.localStorage) {
|
||||
throw new Error('jsdom localStorage is missing: Node built-in web storage is shadowing it. Check test.execArgv in vitest.config.ts.')
|
||||
}
|
||||
|
||||
// Install vue-i18n globally for all component tests so useI18n()/t() resolve real
|
||||
// English messages (matching existing English text assertions) instead of throwing.
|
||||
config.global.plugins = [...(config.global.plugins ?? []), i18n]
|
||||
|
||||
@@ -8,6 +8,12 @@ export default mergeConfig(
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
// Node 26 defines `localStorage` and `sessionStorage` on globalThis, and jsdom shares that
|
||||
// object, so its own storage never gets installed. Node's `localStorage` then reads as
|
||||
// undefined unless the process was started with `--localstorage-file`, and its
|
||||
// `sessionStorage` is a process-wide store no test file can reset. Standing Node's
|
||||
// implementation down leaves jsdom's in place, isolated per test file.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
coverage: {
|
||||
|
||||
Reference in New Issue
Block a user