* fix(errors): show a route's Swedish message even without a keyword match (#2086) getErrorMessage passed a route's free-text `error` / `message` through only if it contained one of ~30 keywords ("kunde inte", "saknas", ...). "Inget skattekonto är registrerat hos Skatteverket." has none, so the skattekonto sync replaced the one sentence that would have helped with "Ett oväntat serverfel uppstod. Försök igen senare.", which is wrong advice for a company without a skattekonto. 155 of the 631 message_sv strings in structured-errors.ts failed the same keyword test. A second way in: looksLikeUserFacingSwedish accepts a string that reads as Swedish (å/ä/ö, a strong Swedish word, or two weak function words) and shows no sign of a technical leak (stack frames, file:line, JS/Node error vocabulary, Postgres/PostgREST/SQL fragments, JSON, URLs). The keyword list stays as the first way in. English framework text still falls through to the status/context fallback, and a registry-wide test pins that every message_sv now passes. Closes #2086 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * test(errors): pin the two call sites whose Swedish messages now pass through (#2086) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
5070041028
commit
c3b7e79af8
@@ -97,13 +97,27 @@ describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
|
||||
expect(body.error.code).toBe('ACCRUAL_NOTHING_TO_DISSOLVE')
|
||||
})
|
||||
|
||||
it('falls back to ACCRUAL_DISSOLVE_FAILED for untyped errors', async () => {
|
||||
it('falls back to ACCRUAL_DISSOLVE_FAILED for untyped errors, carrying a Swedish reason as-is', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new Error('Ingen öppen räkenskapsperiod för 2026-01-01'))
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { reason: string } }
|
||||
}>(await dissolveRequest())
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
|
||||
// The engine's own Swedish sentence reaches the user (issue #2086); it
|
||||
// used to be dropped for lacking a getErrorMessage keyword.
|
||||
expect(body.error.details.reason).toBe('Ingen öppen räkenskapsperiod för 2026-01-01')
|
||||
})
|
||||
|
||||
it('falls back to ACCRUAL_DISSOLVE_FAILED with the generic reason for untyped technical errors', async () => {
|
||||
mockDissolveScheduleNow.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { reason: string } }
|
||||
}>(await dissolveRequest())
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
|
||||
expect(body.error.details.reason).toBe('Något gick fel. Försök igen.')
|
||||
|
||||
@@ -69,10 +69,10 @@ describe('postAction', () => {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 404,
|
||||
// The plain-string envelope this route emits is not recognized as a
|
||||
// finished user sentence, so the status map answers: same text the old
|
||||
// inline getErrorMessage(result, { statusCode }) produced.
|
||||
message: 'Resursen kunde inte hittas.',
|
||||
// The route's own Swedish sentence is shown as-is (issue #2086): it
|
||||
// used to be dropped for lacking one of getErrorMessage's keywords and
|
||||
// replaced by the status map's "Resursen kunde inte hittas.".
|
||||
message: 'Ingen AGI för perioden 2026-04.',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getErrorMessage, getProviderResourceForbiddenMessage } from '../get-error-message'
|
||||
import { getErrorEntry } from '../structured-errors'
|
||||
import {
|
||||
getErrorMessage,
|
||||
getProviderResourceForbiddenMessage,
|
||||
isSwedishUserMessage,
|
||||
looksLikeUserFacingSwedish,
|
||||
} from '../get-error-message'
|
||||
import { getErrorEntry, listErrorCodes } from '../structured-errors'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
@@ -601,3 +606,66 @@ describe('getErrorMessage: INVOICE_SEND_PAYMENT_ACCOUNT_MISSING (#2126)', () =>
|
||||
expect(unknown).toBe(getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_sv)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Issue #2086: the keyword heuristic dropped correct Swedish route messages
|
||||
// that happened to lack one of its ~30 keywords and replaced them with generic
|
||||
// HTTP text whose advice was sometimes wrong. A sentence that reads as Swedish
|
||||
// and shows no sign of a technical leak now passes through.
|
||||
describe('getErrorMessage: Swedish route messages without a keyword pass through (#2086)', () => {
|
||||
it('shows the skattekonto sync reason instead of the generic 500 text', () => {
|
||||
const msg = getErrorMessage(
|
||||
{ error: 'Inget skattekonto är registrerat hos Skatteverket.' },
|
||||
{ statusCode: 500 },
|
||||
)
|
||||
expect(msg).toBe('Inget skattekonto är registrerat hos Skatteverket.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Datumet ligger utanför det valda räkenskapsåret.',
|
||||
'Rättelsen motsvarar ingen ekonomisk händelse: det finns inget att rätta.',
|
||||
'Funktionen är inte implementerad ännu.',
|
||||
])('passes through %s', (text) => {
|
||||
expect(getErrorMessage({ error: text }, { statusCode: 400 })).toBe(text)
|
||||
expect(getErrorMessage({ message: text }, { statusCode: 400 })).toBe(text)
|
||||
})
|
||||
|
||||
it('still hides English and technical text behind the status/context fallback', () => {
|
||||
expect(getErrorMessage({ error: 'Failed to fetch customer' }, { statusCode: 500 })).toBe(
|
||||
'Ett oväntat serverfel uppstod. Försök igen senare.',
|
||||
)
|
||||
expect(
|
||||
getErrorMessage({ error: 'TypeError: Cannot read properties of undefined (reading "id")' }, { statusCode: 500 }),
|
||||
).toBe('Ett oväntat serverfel uppstod. Försök igen senare.')
|
||||
// Swedish words next to a leak are still a leak.
|
||||
expect(
|
||||
getErrorMessage({ error: 'Kontot är trasigt: TypeError: x is not a function' }, { statusCode: 500 }),
|
||||
).toBe('Ett oväntat serverfel uppstod. Försök igen senare.')
|
||||
expect(
|
||||
getErrorMessage({ error: 'Det gick inte att läsa relation "public.invoices"' }, { statusCode: 500 }),
|
||||
).toBe('Ett oväntat serverfel uppstod. Försök igen senare.')
|
||||
})
|
||||
|
||||
it('looksLikeUserFacingSwedish scores on å/ä/ö, a strong Swedish word, or two weak ones, never on English', () => {
|
||||
expect(looksLikeUserFacingSwedish('Inget skattekonto är registrerat hos Skatteverket.')).toBe(true)
|
||||
expect(looksLikeUserFacingSwedish('Det finns inget att rätta.')).toBe(true)
|
||||
expect(looksLikeUserFacingSwedish('Kopplingen misslyckades.')).toBe(true)
|
||||
expect(looksLikeUserFacingSwedish('Ingen fil bifogad.')).toBe(true)
|
||||
expect(looksLikeUserFacingSwedish('Kan hittas med den.')).toBe(true) // two weak words
|
||||
expect(looksLikeUserFacingSwedish('Not found')).toBe(false)
|
||||
expect(looksLikeUserFacingSwedish('Request failed with status 500')).toBe(false)
|
||||
expect(looksLikeUserFacingSwedish('Invalid input: expected string, received undefined')).toBe(false)
|
||||
expect(looksLikeUserFacingSwedish('')).toBe(false)
|
||||
// A lone weak word is not enough ("till" is also English).
|
||||
expect(looksLikeUserFacingSwedish('Redirect till /login')).toBe(false)
|
||||
expect(looksLikeUserFacingSwedish('Set the value for det')).toBe(false)
|
||||
})
|
||||
|
||||
it('every message_sv in the structured-error registry passes the combined test', () => {
|
||||
const failing = listErrorCodes().filter((code) => {
|
||||
const entry = getErrorEntry(code)
|
||||
return entry ? !isSwedishUserMessage(entry.message_sv) : false
|
||||
})
|
||||
expect(failing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,10 +186,97 @@ function tryMatchKnownError(message: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple heuristic to detect already-translated Swedish messages.
|
||||
* If the message contains common Swedish words/patterns, pass it through.
|
||||
* Swedish tokens that mark a sentence as Swedish. STRONG ones are
|
||||
* unambiguous (never English, rare in technical output) and count 2 on their
|
||||
* own; WEAK ones are common function words that also exist in English or are
|
||||
* too short to be decisive ("till", "den", "det") and count 1 each. Words
|
||||
* that are plainly English as well ("under", "men", "om", "en", "vi") are
|
||||
* left out on purpose, so an English framework message cannot score on them.
|
||||
*/
|
||||
function isSwedishUserMessage(message: string): boolean {
|
||||
const SWEDISH_STRONG_WORDS = [
|
||||
'och', 'att', 'inte', 'är', 'ska', 'finns', 'ingen', 'inget', 'inga', 'redan',
|
||||
'bara', 'hos', 'från', 'eller', 'utan', 'också', 'endast', 'ännu', 'igen',
|
||||
'kunde', 'gick', 'går', 'måste', 'får', 'saknas', 'lyckades', 'misslyckades',
|
||||
'bifogad', 'svarade',
|
||||
]
|
||||
const SWEDISH_WEAK_WORDS = [
|
||||
'för', 'med', 'till', 'det', 'den', 'ett', 'av', 'på', 'som', 'har', 'kan',
|
||||
'när', 'över', 'mot', 'vid', 'efter', 'innan', 'alla', 'sedan', 'här', 'där',
|
||||
'din', 'ditt', 'dina', 'denna', 'detta', 'dessa', 'minst', 'högst',
|
||||
]
|
||||
// The strong list is probed with .test(), so it must NOT be global: a global
|
||||
// regex keeps lastIndex between calls and silently fails the next message.
|
||||
// The weak list is iterated with matchAll(), which requires the g flag and
|
||||
// clones the regex per call.
|
||||
const wordListRe = (words: string[], flags: string) =>
|
||||
new RegExp(`(^|[^\\p{L}])(${words.join('|')})(?=$|[^\\p{L}])`, flags)
|
||||
const SWEDISH_STRONG_RE = wordListRe(SWEDISH_STRONG_WORDS, 'iu')
|
||||
const SWEDISH_WEAK_RE = wordListRe(SWEDISH_WEAK_WORDS, 'giu')
|
||||
|
||||
/**
|
||||
* Signs that a string is a technical leak rather than a sentence written for
|
||||
* the user: stack frames, file:line references, JS/Node error vocabulary,
|
||||
* Postgres/PostgREST/SQL fragments, JSON, URLs. A message carrying any of
|
||||
* these is never shown raw, whatever language it is in.
|
||||
*/
|
||||
const TECHNICAL_LEAK_PATTERNS: RegExp[] = [
|
||||
/\bat \S+ \(/, // stack frame: "at fn (file:1:2)"
|
||||
/\.(?:ts|tsx|js|mjs|cjs):\d+/, // file:line
|
||||
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError|EvalError)\b/,
|
||||
/cannot read propert/i,
|
||||
/is not a function\b/i,
|
||||
/is not defined\b/i,
|
||||
/\bundefined\b/,
|
||||
/\bNaN\b/,
|
||||
/\bPGRST\d+/,
|
||||
/\bSQLSTATE\b/,
|
||||
/violates .*constraint/i,
|
||||
/duplicate key value/i,
|
||||
/relation "/i,
|
||||
/column "/i,
|
||||
/syntax error at/i,
|
||||
/\bE(?:CONN\w+|TIMEDOUT|NOTFOUND|PIPE|HOSTUNREACH)\b/,
|
||||
/fetch failed/i,
|
||||
/unexpected token/i,
|
||||
/\{\s*"/, // JSON object start
|
||||
/https?:\/\//,
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether a free-text string reads as a Swedish sentence written for the user
|
||||
* (issue #2086): it carries å/ä/ö or Swedish words, and shows no sign of
|
||||
* being a technical leak (see TECHNICAL_LEAK_PATTERNS). Scoring: å/ä/ö or
|
||||
* any STRONG word counts 2, each distinct WEAK word 1, pass at 2. So "Inget
|
||||
* skattekonto är registrerat hos Skatteverket." and "Kopplingen misslyckades."
|
||||
* pass; "Failed to fetch customer", "Redirect till /login" and
|
||||
* "TypeError: x is not a function" do not.
|
||||
*/
|
||||
export function looksLikeUserFacingSwedish(message: string): boolean {
|
||||
const text = message.trim()
|
||||
if (!text) return false
|
||||
if (TECHNICAL_LEAK_PATTERNS.some((p) => p.test(text))) return false
|
||||
if (/[åäöÅÄÖ]/.test(text)) return true
|
||||
if (SWEDISH_STRONG_RE.test(text)) return true
|
||||
const weak = new Set<string>()
|
||||
for (const m of text.matchAll(SWEDISH_WEAK_RE)) weak.add(m[2].toLowerCase())
|
||||
return weak.size >= 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route's free-text `error` / `message` string is a user-facing
|
||||
* Swedish message that should be shown as-is.
|
||||
*
|
||||
* Two ways in. The keyword list below is the original test; it stays because
|
||||
* callers rely on the odd tokens it lets through (e.g. "session"). It was also
|
||||
* the ONLY test until issue #2086: a correct sentence without one of the ~30
|
||||
* keywords ("Inget skattekonto är registrerat hos Skatteverket.") was dropped
|
||||
* and replaced with the generic HTTP-500 text, whose "försök igen senare"
|
||||
* advice was wrong for the case. 155 of the 631 message_sv strings in
|
||||
* structured-errors.ts failed the keyword test. looksLikeUserFacingSwedish is
|
||||
* the second way in, and a registry-wide test pins that every message_sv
|
||||
* passes one of the two.
|
||||
*/
|
||||
export function isSwedishUserMessage(message: string): boolean {
|
||||
const swedishPatterns = [
|
||||
/kunde inte/i,
|
||||
/kan inte/i,
|
||||
@@ -225,7 +312,7 @@ function isSwedishUserMessage(message: string): boolean {
|
||||
/verifikation/i,
|
||||
/importera|importen/i,
|
||||
]
|
||||
return swedishPatterns.some((p) => p.test(message))
|
||||
return swedishPatterns.some((p) => p.test(message)) || looksLikeUserFacingSwedish(message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user