fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message (#1811)
* fix(mcp): over-long reason gets VALIDATION_ERROR and a specific Swedish message gnubok_reverse_journal_entry / gnubok_undo_sie_import cap `reason` at 500 characters, but exceeding it produced code UNKNOWN_ERROR with message_sv "Något gick fel. Försök igen." while the cause sat only in message_en. getStructuredError now infers VALIDATION_ERROR from the message and getErrorMessage maps it to "Motiveringen får vara högst 500 tecken.". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(errors): pin the reason-length pattern to 500 and assert the envelope on undo_sie_import Review findings: the Swedish message hard-codes 500, so the pattern must match that limit only; the undo_sie_import 501-char test now asserts code and both localized messages like the reverse test does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
158ef0f484
commit
d35c401c0c
@@ -139,16 +139,25 @@ describe('gnubok_undo_sie_import: stage-time validation', () => {
|
||||
).rejects.toThrow(/import_id/i)
|
||||
})
|
||||
|
||||
it('rejects reason longer than 500 characters', async () => {
|
||||
it('rejects reason longer than 500 characters as VALIDATION_ERROR with a Swedish message', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
undoTool.execute(
|
||||
const { getStructuredError } = await import('@/lib/errors/get-structured-error')
|
||||
let thrown: unknown
|
||||
try {
|
||||
await undoTool.execute(
|
||||
{ import_id: 'imp-1', reason: 'x'.repeat(501) },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/500/)
|
||||
)
|
||||
} catch (err) {
|
||||
thrown = err
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
const envelope = getStructuredError(thrown)
|
||||
expect(envelope.code).toBe('VALIDATION_ERROR')
|
||||
expect(envelope.message_sv).toBe('Motiveringen får vara högst 500 tecken.')
|
||||
expect(envelope.message_en).toMatch(/500 characters or fewer/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -749,6 +749,29 @@ describe('gnubok_reverse_journal_entry: staging gates', () => {
|
||||
).rejects.toThrow(/entry_id is required/i)
|
||||
})
|
||||
|
||||
it('rejects a reason over 500 characters with a VALIDATION_ERROR code and a Swedish message', async () => {
|
||||
// Customer report: the envelope said "Något gick fel. Försök igen." in
|
||||
// Swedish while the cause was only in the English field.
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const { getStructuredError } = await import('@/lib/errors/get-structured-error')
|
||||
let thrown: unknown
|
||||
try {
|
||||
await reverseEntry.execute(
|
||||
{ entry_id: '11111111-1111-1111-1111-111111111111', reason: 'x'.repeat(501) },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)
|
||||
} catch (err) {
|
||||
thrown = err
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
const envelope = getStructuredError(thrown)
|
||||
expect(envelope.code).toBe('VALIDATION_ERROR')
|
||||
expect(envelope.message_sv).toBe('Motiveringen får vara högst 500 tecken.')
|
||||
expect(envelope.message_en).toMatch(/500 characters or fewer/)
|
||||
})
|
||||
|
||||
it('rejects when the original entry is not posted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
|
||||
@@ -592,6 +592,18 @@ const AUTO_PERIOD_DATE_KEYS = [
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/**
|
||||
* `reason` is capped at 500 characters (inputSchema maxLength). Hosts do not
|
||||
* always enforce inputSchema, so the tools re-check at runtime. The message
|
||||
* text is what the error layer keys on: getStructuredError infers
|
||||
* VALIDATION_ERROR from it and getErrorMessage maps it to a specific Swedish
|
||||
* line (a structured `code` on the error would win over the message and
|
||||
* collapse the Swedish text to the generic validation fallback).
|
||||
*/
|
||||
function reasonTooLongError(): Error {
|
||||
return new Error('reason must be 500 characters or fewer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a single account-number argument to a trimmed string (validated
|
||||
* against ACCOUNT_NUMBER_RE from lib/invariants/account-number). Accepts a
|
||||
@@ -15107,7 +15119,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!importId) throw new Error('import_id is required')
|
||||
if (reason !== undefined && reason.length > 500) {
|
||||
throw new Error('reason must be 500 characters or fewer')
|
||||
throw reasonTooLongError()
|
||||
}
|
||||
|
||||
// Pre-flight mirrors undoSIEImport: confirm row exists, belongs to
|
||||
@@ -15734,7 +15746,7 @@ export const tools: McpTool[] = [
|
||||
throw new Error('reversal_date must be ISO yyyy-MM-dd')
|
||||
}
|
||||
if (reason !== undefined && reason.length > 500) {
|
||||
throw new Error('reason must be 500 characters or fewer')
|
||||
throw reasonTooLongError()
|
||||
}
|
||||
|
||||
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
|
||||
|
||||
@@ -35,6 +35,12 @@ describe('getStructuredError', () => {
|
||||
expect(result.remediation?.tool).toBe('gnubok_lock_period')
|
||||
})
|
||||
|
||||
it('maps an over-long reason to VALIDATION_ERROR with a specific Swedish message', () => {
|
||||
const result = getStructuredError(new Error('reason must be 500 characters or fewer'))
|
||||
expect(result.code).toBe('VALIDATION_ERROR')
|
||||
expect(result.message_sv).toBe('Motiveringen får vara högst 500 tecken.')
|
||||
})
|
||||
|
||||
it('infers PERIOD_HAS_UNBOOKED_TRANSACTIONS from Swedish lock-error message', () => {
|
||||
const result = getStructuredError(
|
||||
new Error('Kan inte låsa period: 3 affärstransaktion(er) saknar bokföring.')
|
||||
|
||||
@@ -98,6 +98,10 @@ const GENERIC_FALLBACK: Bilingual = { sv: 'Något gick fel. Försök igen.', en:
|
||||
|
||||
// Known error patterns → user-friendly Swedish messages
|
||||
const ERROR_PATTERN_MAP: [RegExp, string | null][] = [
|
||||
[
|
||||
/reason must be 500 characters or fewer/i,
|
||||
'Motiveringen får vara högst 500 tecken.',
|
||||
],
|
||||
[
|
||||
/locked\/closed fiscal period/i,
|
||||
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
|
||||
|
||||
@@ -158,6 +158,7 @@ function inferCode(message: string): string | null {
|
||||
if (/Bokföringen är låst/i.test(message)) return 'PERIOD_LOCKED'
|
||||
if (/Transaction not found/i.test(message)) return 'NOT_FOUND'
|
||||
if (/Invoice not found/i.test(message)) return 'NOT_FOUND'
|
||||
if (/must be \d+ characters or fewer/i.test(message)) return 'VALIDATION_ERROR'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user