diff --git a/src/components/import/ImportWalletPage.tsx b/src/components/import/ImportWalletPage.tsx index 7e46f7b4..da5304ab 100644 --- a/src/components/import/ImportWalletPage.tsx +++ b/src/components/import/ImportWalletPage.tsx @@ -21,6 +21,7 @@ import { MAX_WALLET_NAME_LENGTH } from '@/constants/jam' import { JM_DEFAULT_WALLET_TYPE, JM_WALLET_FILE_EXTENSION } from '@/constants/jm' import { routes } from '@/constants/routes' import { useApiClient } from '@/hooks/useApiClient' +import { getErrorReason } from '@/lib/errorReason' import { hashPassword } from '@/lib/hash' import { withQueryDelay } from '@/lib/queryClient' import { walletDisplayNameToFileName } from '@/lib/utils' @@ -38,28 +39,6 @@ interface ImportWalletFormValues { seedPhrase: string } -const getImportErrorReason = (error: unknown, fallback: string) => { - if (error instanceof Error && error.message) return error.message - if (typeof error === 'string' && error.trim()) return error - if (error && typeof error === 'object') { - const maybeError = error as { - message?: unknown - error_description?: unknown - detail?: unknown - } - if (typeof maybeError.error_description === 'string' && maybeError.error_description.trim()) { - return maybeError.error_description - } - if (typeof maybeError.message === 'string' && maybeError.message.trim()) { - return maybeError.message - } - if (typeof maybeError.detail === 'string' && maybeError.detail.trim()) { - return maybeError.detail - } - } - return fallback -} - const normalizeSeedPhrase = (value?: string) => (value ?? '') .toLowerCase() @@ -174,7 +153,7 @@ const ImportWalletPage = () => { toast.success(t('import_wallet.success.title')) await navigate(routes.home) } catch (error: unknown) { - const reason = getImportErrorReason(error, t('global.errors.reason_unknown')) + const reason = getErrorReason(error, t('global.errors.reason_unknown')) toast.error(t('import_wallet.error_importing_failed', { reason })) } } diff --git a/src/lib/config.ts b/src/lib/config.ts index ee0b65b6..1e186e03 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,6 +2,7 @@ import { createClient } from '@joinmarket-webui/joinmarket-api-ts' import type { Client } from '@joinmarket-webui/joinmarket-api-ts/client' import type { ClientOptions, UnlockWalletResponse } from '@joinmarket-webui/joinmarket-api-ts/jm' import { isDevMode } from '@/constants/debugFeatures' +import { normalizeAppError } from '@/lib/errorReason' import { authStore } from '@/store/authStore' type ApiToken = UnlockWalletResponse['token'] @@ -19,6 +20,10 @@ function loggingResponseInterceptor(response: Response) { return response } +function normalizeErrorInterceptor(error: unknown) { + return normalizeAppError(error) +} + const createJamAuthenticationMiddleware = () => { // eslint-disable-next-line unicorn/consistent-function-scoping -- false positive return (request: Request) => { @@ -40,6 +45,7 @@ export const createApiClient = (): Client => { const jamAuthMiddleware = createJamAuthenticationMiddleware() client.interceptors.request.use(jamAuthMiddleware) + client.interceptors.error.use(normalizeErrorInterceptor) if (isDevMode()) { client.interceptors.request.use(loggingRequestInterceptor) diff --git a/src/lib/errorReason.test.ts b/src/lib/errorReason.test.ts index d046e5c7..9711018f 100644 --- a/src/lib/errorReason.test.ts +++ b/src/lib/errorReason.test.ts @@ -1,5 +1,36 @@ import { describe, it, expect } from 'vitest' -import { getErrorReason } from './errorReason' +import { getErrorReason, normalizeAppError } from './errorReason' + +describe('normalizeAppError', () => { + it('normalizes direct string errors', () => { + expect(normalizeAppError('backend exploded')).toEqual({ message: 'backend exploded' }) + }) + + it('normalizes Error instances', () => { + expect(normalizeAppError(new Error('request failed'))).toEqual({ message: 'request failed' }) + }) + + it('keeps message and error_description when present', () => { + const error = { + message: 'Request failed with status 400', + error_description: 'Wallet is already unlocked', + } + expect(normalizeAppError(error)).toEqual({ + message: 'Request failed with status 400', + error_description: 'Wallet is already unlocked', + }) + }) + + it('uses error_description as message when message is missing', () => { + const error = { + error_description: 'Invalid wallet password', + } + expect(normalizeAppError(error)).toEqual({ + message: 'Invalid wallet password', + error_description: 'Invalid wallet password', + }) + }) +}) describe('getErrorReason', () => { it('returns fallback when nothing usable is present', () => { @@ -20,15 +51,14 @@ describe('getErrorReason', () => { expect(getErrorReason(error, 'fallback')).toBe('Wallet is already unlocked') }) - it('prefers detail over message when error_description is missing', () => { + it('returns message when error_description is missing', () => { const error = { message: 'Request failed with status 422', - detail: 'Seed phrase checksum failed', } - expect(getErrorReason(error, 'fallback')).toBe('Seed phrase checksum failed') + expect(getErrorReason(error, 'fallback')).toBe('Request failed with status 422') }) - it('extracts nested backend reason from response.data', () => { + it('returns fallback for non-normalized nested objects', () => { const error = { response: { data: { @@ -36,15 +66,6 @@ describe('getErrorReason', () => { }, }, } - expect(getErrorReason(error, 'fallback')).toBe('Invalid wallet password') - }) - - it('extracts nested backend reason from error payload', () => { - const error = { - error: { - detail: 'Coinjoin is currently in progress', - }, - } - expect(getErrorReason(error, 'fallback')).toBe('Coinjoin is currently in progress') + expect(getErrorReason(error, 'fallback')).toBe('fallback') }) }) diff --git a/src/lib/errorReason.ts b/src/lib/errorReason.ts index 447341c2..7dbbabe8 100644 --- a/src/lib/errorReason.ts +++ b/src/lib/errorReason.ts @@ -2,71 +2,41 @@ const toNonEmptyString = (value: unknown): string | undefined => { return typeof value === 'string' && value.trim() ? value.trim() : undefined } -const extractReason = (error: unknown, depth = 0): string | undefined => { - if (depth > 4 || error === null || error === undefined) { - return undefined +export interface AppError { + message: string + error_description?: string +} + +export const normalizeAppError = (error: unknown): AppError => { + const asString = toNonEmptyString(error) + if (asString) { + return { message: asString } } - const directString = toNonEmptyString(error) - if (directString) { - return directString + if (error instanceof Error) { + return { message: error.message.trim() } } - if (typeof error !== 'object') { - return undefined - } + if (typeof error === 'object' && error !== null) { + const maybeError = error as { message?: unknown; error_description?: unknown } + const message = toNonEmptyString(maybeError.message) + const errorDescription = toNonEmptyString(maybeError.error_description) - const maybeError = error as { - message?: unknown - error_description?: unknown - detail?: unknown - statusText?: unknown - title?: unknown - error?: unknown - response?: { data?: unknown } | unknown - data?: unknown - body?: unknown - cause?: unknown - } - - const prioritizedReason = [ - maybeError.error_description, - maybeError.detail, - maybeError.message, - maybeError.statusText, - maybeError.title, - ] - .map((value) => toNonEmptyString(value)) - .find(Boolean) - - if (prioritizedReason) { - return prioritizedReason - } - - const responseData = - maybeError.response && typeof maybeError.response === 'object' - ? (maybeError.response as { data?: unknown }).data - : undefined - - const nestedCandidates = [ - maybeError.error, - maybeError.response, - responseData, - maybeError.data, - maybeError.body, - maybeError.cause, - ] - - for (const candidate of nestedCandidates) { - const nestedReason = extractReason(candidate, depth + 1) - if (nestedReason) { - return nestedReason + if (message && errorDescription) { + return { message, error_description: errorDescription } + } + if (message) { + return { message } + } + if (errorDescription) { + return { message: errorDescription, error_description: errorDescription } } } - return undefined + return { message: '' } } export const getErrorReason = (error: unknown, fallback: string): string => { - return extractReason(error) ?? fallback + const normalized = normalizeAppError(error) + return toNonEmptyString(normalized.error_description) ?? toNonEmptyString(normalized.message) ?? fallback }