diff --git a/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts index d367bef6..0fc0b96f 100644 --- a/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts +++ b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts @@ -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.') diff --git a/lib/browser/__tests__/post-action.test.ts b/lib/browser/__tests__/post-action.test.ts index 402c15e5..6d401d23 100644 --- a/lib/browser/__tests__/post-action.test.ts +++ b/lib/browser/__tests__/post-action.test.ts @@ -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.', }) }) diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index 8f77ec20..01ab9aa9 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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([]) + }) +}) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 8eb6089b..c680905d 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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() + 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) } /**