From 8ad089d5fa97edc895354b13fe7ea0de57c089e2 Mon Sep 17 00:00:00 2001 From: eitighis Date: Tue, 6 Oct 2026 12:38:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20mapApiError=20code=E2=86=92m?= =?UTF-8?q?essage=20mapping=20utility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #124 Add src/lib/errors.ts: a pure mapApiError(err, opts) that normalizes an AxiosError / Error / unknown into { code, status, userMessage, retryable, raw } using a frozen ERROR_MESSAGES table keyed by backend error code. The UI never interpolates a raw server message into userMessage (default off); the original error travels in raw for telemetry only. Adds 12 unit tests covering code extraction order, the message table, status-derived codes, retryable mapping, and a regression that raw server text is not surfaced when showRaw is false. Workstream 1 (the utility) only; adoption across pages/hooks is web-err-05. --- src/lib/__tests__/errors.test.ts | 96 +++++++++++++++++++++++++++++++ src/lib/errors.ts | 98 ++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/lib/__tests__/errors.test.ts create mode 100644 src/lib/errors.ts diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts new file mode 100644 index 0000000..f1a48ac --- /dev/null +++ b/src/lib/__tests__/errors.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { mapApiError, ERROR_MESSAGES } from '../errors' + +describe('mapApiError', () => { + it('reads a backend errorCode from the response envelope (acceptance #1)', () => { + const result = mapApiError({ response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET' } } }) + expect(result).toEqual({ + code: 'ROLE_ALREADY_SET', + status: 409, + userMessage: ERROR_MESSAGES.ROLE_ALREADY_SET, + retryable: false, + raw: expect.anything(), + }) + }) + + it('falls back to data.code when errorCode is absent', () => { + const result = mapApiError({ response: { status: 400, data: { code: 'VALIDATION' } } }) + expect(result.code).toBe('VALIDATION') + expect(result.userMessage).toBe(ERROR_MESSAGES.VALIDATION) + }) + + it('resolves every entry in the message table', () => { + for (const code of Object.keys(ERROR_MESSAGES)) { + if (code === 'DEFAULT') continue + const result = mapApiError({ response: { status: 400, data: { errorCode: code } } }) + expect(result.code).toBe(code) + expect(result.userMessage).toBe(ERROR_MESSAGES[code]) + } + }) + + it('falls back to DEFAULT for an unknown code (never throws)', () => { + const result = mapApiError({ response: { status: 418, data: { errorCode: 'A_BRAND_NEW_CODE' } } }) + expect(result.code).toBe('A_BRAND_NEW_CODE') + expect(result.userMessage).toBe(ERROR_MESSAGES.DEFAULT) + }) + + it('derives HTTP_ codes when the body carries no code', () => { + expect(mapApiError({ response: { status: 404, data: {} } }).code).toBe('HTTP_404') + expect(mapApiError({ response: { status: 503, data: {} } }).code).toBe('HTTP_503') + }) + + it('maps a non-axios Error to UNKNOWN + DEFAULT (acceptance #2)', () => { + const result = mapApiError(new Error('boom')) + expect(result.code).toBe('UNKNOWN') + expect(result.userMessage).toBe(ERROR_MESSAGES.DEFAULT) + expect(result.retryable).toBe(false) + }) + + it('maps a response-less axios error to NETWORK, retryable (acceptance #3)', () => { + expect(mapApiError({ isAxiosError: true, request: {}, message: 'Network Error' })).toMatchObject({ + code: 'NETWORK', + retryable: true, + }) + expect(mapApiError({ request: {} }).code).toBe('NETWORK') + }) + + it('sets retryable: true for NETWORK and 5xx, false for 4xx', () => { + expect(mapApiError({ request: {} }).retryable).toBe(true) + expect(mapApiError({ response: { status: 500, data: {} } }).retryable).toBe(true) + expect(mapApiError({ response: { status: 502, data: {} } }).retryable).toBe(true) + expect(mapApiError({ response: { status: 400, data: {} } }).retryable).toBe(false) + expect(mapApiError({ response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET' } } }).retryable).toBe(false) + }) + + it('never surfaces the raw server message when showRaw is false (regression)', () => { + const leak = 'ERROR: duplicate key value violates unique constraint "users_pkey"' + const result = mapApiError( + { response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET', message: leak } } }, + { showRaw: false }, + ) + expect(result.userMessage).toBe(ERROR_MESSAGES.ROLE_ALREADY_SET) + expect(result.userMessage).not.toContain('constraint') + expect(result.userMessage).not.toContain(leak) + }) + + it('appends the raw message only when showRaw is explicitly enabled (dev)', () => { + const result = mapApiError( + { response: { status: 400, data: { errorCode: 'VALIDATION', message: 'amount must be > 0' } } }, + { showRaw: true }, + ) + expect(result.userMessage).toContain(ERROR_MESSAGES.VALIDATION) + expect(result.userMessage).toContain('amount must be > 0') + }) + + it('always carries the original error in raw for telemetry', () => { + const original = new Error('for reportError') + expect(mapApiError(original).raw).toBe(original) + }) + + it('tolerates null/undefined/string input without throwing', () => { + expect(mapApiError(null).code).toBe('UNKNOWN') + expect(mapApiError(undefined).code).toBe('UNKNOWN') + expect(mapApiError('nope').code).toBe('UNKNOWN') + expect(mapApiError(null).userMessage).toBe(ERROR_MESSAGES.DEFAULT) + }) +}) diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..8e36c4a --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,98 @@ +/** + * API error → user-facing message mapping. + * + * One table keyed by a stable backend error code. The UI shows ONLY mapped, + * safe text — a raw server message is never interpolated into `userMessage` + * (server strings can carry internals like SQL fragments or stack hints). The + * original error travels in `raw` for telemetry (hand it to `reportError`), + * and must never be rendered to the DOM. + * + * Pure and side-effect free: it reads the error shape and returns a value. + */ + +export interface ApiError { + /** Stable code: a backend error code, or a derived `NETWORK` / `HTTP_` / `UNKNOWN`. */ + code: string + /** HTTP status, when the request reached the server. */ + status?: number + /** Safe, human text to show the user. Never contains the raw server message in production. */ + userMessage: string + /** Whether retrying the same request could plausibly succeed. */ + retryable: boolean + /** The original error — for telemetry only (`reportError`); never render this. */ + raw?: unknown +} + +/** + * Frozen code → message table. Unknown codes fall back to `DEFAULT`. + * Codes are opaque strings owned by the backend; add entries as the API grows. + */ +export const ERROR_MESSAGES: Readonly> = Object.freeze({ + INSUFFICIENT_LIQUIDITY: 'Not enough liquidity in the pool for this amount.', + INSUFFICIENT_SHARES: "You don't have that many shares.", + ROLE_ALREADY_SET: "Your role is already set and can't be changed.", + UNAUTHENTICATED: 'Your session expired. Please reconnect your wallet.', + VALIDATION: 'Please check the amounts you entered.', + NETWORK: 'Network error. Check your connection and try again.', + DEFAULT: 'Something went wrong. Please try again.', +}) + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null ? (value as Record) : undefined +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +/** + * Dev-only switch to append the raw server message for debugging. Reads the + * build-time `VITE_SHOW_RAW_ERRORS` env (string `'true'`); defaults off and is + * never a user-controlled value. Must never be `'true'` in a production build. + */ +function defaultShowRaw(): boolean { + return import.meta.env?.VITE_SHOW_RAW_ERRORS === 'true' +} + +/** + * Normalize any thrown value (AxiosError, plain Error, or unknown) into a safe + * {@link ApiError}. See the module header for the guarantees. + * + * @param err the caught value + * @param opts `showRaw` overrides the env default (used by callers/tests) + */ +export function mapApiError(err: unknown, opts?: { showRaw?: boolean }): ApiError { + const showRaw = opts?.showRaw ?? defaultShowRaw() + const record = asRecord(err) + const response = asRecord(record?.response) + + let code: string + let status: number | undefined + + if (response) { + // the request reached the server and came back with a status + status = typeof response.status === 'number' ? response.status : undefined + const data = asRecord(response.data) + code = + readString(data?.errorCode) ?? + readString(data?.code) ?? + (status !== undefined ? `HTTP_${status}` : 'UNKNOWN') + } else if (record && (record.isAxiosError === true || 'request' in record)) { + // an axios error carrying a request but no response = the call never completed + code = 'NETWORK' + } else { + // a plain Error or any non-request-shaped value + code = 'UNKNOWN' + } + + const retryable = code === 'NETWORK' || (status !== undefined && status >= 500 && status < 600) + + let userMessage = ERROR_MESSAGES[code] ?? ERROR_MESSAGES.DEFAULT + + if (showRaw) { + const rawMessage = readString(asRecord(response?.data)?.message) ?? readString(record?.message) + if (rawMessage) userMessage = `${userMessage} [${rawMessage}]` + } + + return { code, status, userMessage, retryable, raw: err } +}