From 9b9a74bec0e553bab20f7cc1fa1fa5dc33fced9d Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 14 Jun 2026 11:40:39 +0700 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20share=20the=20API-error=20?= =?UTF-8?q?=E2=86=92=20message=20mapping=20across=20account=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three account forms duplicated the getApiErrorCode + ERROR_BY_CODE lookup pattern verbatim, and each carried a dead `AuthRequired` entry that the axios interceptor already handles (hard redirect) before the form's catch can render it. - add resolveApiErrorMessage(err, map, fallback) next to the axios error helpers - email/name/password forms use it and drop the unreachable AuthRequired entry - unit-test the helper's precedence (mapped code > server msg > fallback) Co-Authored-By: Claude Fable 5 --- .../authentication/email-change-form.test.tsx | 2 +- .../authentication/email-change-form.tsx | 6 ++--- .../authentication/name-change-form.test.tsx | 2 +- .../authentication/name-change-form.tsx | 6 ++--- .../password-change-form.test.tsx | 2 +- .../authentication/password-change-form.tsx | 6 ++--- frontend/src/lib/axios.test.ts | 24 +++++++++++++++++++ frontend/src/lib/axios.ts | 10 ++++++++ 8 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/axios.test.ts diff --git a/frontend/src/features/authentication/email-change-form.test.tsx b/frontend/src/features/authentication/email-change-form.test.tsx index 958cd370..8877f1c1 100644 --- a/frontend/src/features/authentication/email-change-form.test.tsx +++ b/frontend/src/features/authentication/email-change-form.test.tsx @@ -8,7 +8,7 @@ const { patchUser, put, refreshAuthInfo } = vi.hoisted(() => ({ refreshAuthInfo: vi.fn().mockResolvedValue(undefined), })); -// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed. +// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed. vi.mock('@/lib/axios', async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/src/features/authentication/email-change-form.tsx b/frontend/src/features/authentication/email-change-form.tsx index aeae139d..4eadb637 100644 --- a/frontend/src/features/authentication/email-change-form.tsx +++ b/frontend/src/features/authentication/email-change-form.tsx @@ -9,7 +9,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { FormSubmitButton } from '@/components/ui/form-submit-button'; import { Input } from '@/components/ui/input'; import { InputPassword } from '@/components/ui/input-password'; -import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios'; +import { api, resolveApiErrorMessage } from '@/lib/axios'; import { useUser } from '@/providers/user-provider'; const emailChangeSchema = z.object({ @@ -24,7 +24,6 @@ const emailChangeSchema = z.object({ }); const ERROR_BY_CODE: Record = { - AuthRequired: 'Authentication required', 'Users.ChangeEmailCurrentUser.EmailAlreadyExists': 'Email address is already in use', 'Users.ChangeEmailCurrentUser.InvalidCurrentPassword': 'Current password is incorrect', 'Users.ChangeEmailCurrentUser.InvalidEmail': 'New email does not meet requirements', @@ -68,8 +67,7 @@ export function EmailChangeForm({ isModal = true, onCancel, onSuccess }: EmailCh onSuccess?.(); } catch (err: unknown) { - const code = getApiErrorCode(err); - setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to update email')); + setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to update email')); } }; diff --git a/frontend/src/features/authentication/name-change-form.test.tsx b/frontend/src/features/authentication/name-change-form.test.tsx index 1ab783cd..18c88a46 100644 --- a/frontend/src/features/authentication/name-change-form.test.tsx +++ b/frontend/src/features/authentication/name-change-form.test.tsx @@ -8,7 +8,7 @@ const { patchUser, put, refreshAuthInfo } = vi.hoisted(() => ({ refreshAuthInfo: vi.fn().mockResolvedValue(undefined), })); -// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed. +// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed. vi.mock('@/lib/axios', async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/src/features/authentication/name-change-form.tsx b/frontend/src/features/authentication/name-change-form.tsx index a7f03e6b..4b46434a 100644 --- a/frontend/src/features/authentication/name-change-form.tsx +++ b/frontend/src/features/authentication/name-change-form.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { FormSubmitButton } from '@/components/ui/form-submit-button'; import { Input } from '@/components/ui/input'; -import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios'; +import { api, resolveApiErrorMessage } from '@/lib/axios'; import { useUser } from '@/providers/user-provider'; const nameChangeSchema = z.object({ @@ -20,7 +20,6 @@ const nameChangeSchema = z.object({ }); const ERROR_BY_CODE: Record = { - AuthRequired: 'Authentication required', 'Users.ChangeNameCurrentUser.InvalidName': 'New name does not meet requirements', 'Users.NotFound': 'User not found', }; @@ -57,8 +56,7 @@ export function NameChangeForm({ isModal = true, onCancel, onSuccess }: NameChan onSuccess?.(); } catch (err: unknown) { - const code = getApiErrorCode(err); - setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to update name')); + setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to update name')); } }; diff --git a/frontend/src/features/authentication/password-change-form.test.tsx b/frontend/src/features/authentication/password-change-form.test.tsx index a4cce87e..42958b27 100644 --- a/frontend/src/features/authentication/password-change-form.test.tsx +++ b/frontend/src/features/authentication/password-change-form.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { put } = vi.hoisted(() => ({ put: vi.fn() })); -// Keep the pure helpers (`getApiErrorCode`, `getApiErrorMessage`) real — only the network call is stubbed. +// Keep the real error-mapping helper (`resolveApiErrorMessage`) — only the network call is stubbed. vi.mock('@/lib/axios', async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/src/features/authentication/password-change-form.tsx b/frontend/src/features/authentication/password-change-form.tsx index b81c4b46..c81faabe 100644 --- a/frontend/src/features/authentication/password-change-form.tsx +++ b/frontend/src/features/authentication/password-change-form.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { FormSubmitButton } from '@/components/ui/form-submit-button'; import { InputPassword } from '@/components/ui/input-password'; -import { api, getApiErrorCode, getApiErrorMessage } from '@/lib/axios'; +import { api, resolveApiErrorMessage } from '@/lib/axios'; const passwordChangeSchema = z .object({ @@ -48,7 +48,6 @@ const passwordChangeSchema = z }); const ERROR_BY_CODE: Record = { - AuthRequired: 'Authentication required', 'Users.ChangePasswordCurrentUser.InvalidCurrentPassword': 'Current password is incorrect', 'Users.ChangePasswordCurrentUser.InvalidNewPassword': 'New password does not meet requirements', 'Users.ChangePasswordCurrentUser.InvalidPassword': 'Password validation failed', @@ -98,8 +97,7 @@ export function PasswordChangeForm({ onSuccess?.(); } catch (err: unknown) { - const code = getApiErrorCode(err); - setError((code && ERROR_BY_CODE[code]) ?? getApiErrorMessage(err, 'Failed to change password')); + setError(resolveApiErrorMessage(err, ERROR_BY_CODE, 'Failed to change password')); } }; diff --git a/frontend/src/lib/axios.test.ts b/frontend/src/lib/axios.test.ts new file mode 100644 index 00000000..c89ee02e --- /dev/null +++ b/frontend/src/lib/axios.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveApiErrorMessage } from './axios'; + +const apiError = (code: string, msg: string) => ({ response: { data: { code, msg, status: 'error' } } }); +const messages = { 'Users.NotFound': 'User not found' }; + +describe('resolveApiErrorMessage', () => { + it('prefers a mapped code over the raw server message', () => { + expect(resolveApiErrorMessage(apiError('Users.NotFound', 'user not found'), messages, 'fallback')).toBe( + 'User not found', + ); + }); + + it('falls back to the server message for an unmapped code', () => { + expect(resolveApiErrorMessage(apiError('Some.Other.Code', 'raw server message'), messages, 'fallback')).toBe( + 'raw server message', + ); + }); + + it('uses the fallback when there is neither a mapped code nor a server message', () => { + expect(resolveApiErrorMessage({}, messages, 'fallback')).toBe('fallback'); + }); +}); diff --git a/frontend/src/lib/axios.ts b/frontend/src/lib/axios.ts index 96d81ead..8af1b094 100644 --- a/frontend/src/lib/axios.ts +++ b/frontend/src/lib/axios.ts @@ -255,5 +255,15 @@ export const getApiErrorCode = (error: unknown): string | undefined => { return responseData && typeof responseData === 'object' ? (responseData as ApiErrorResponse).code : undefined; }; +export const resolveApiErrorMessage = ( + error: unknown, + messagesByCode: Record, + fallback: string, +): string => { + const code = getApiErrorCode(error); + + return (code && messagesByCode[code]) ?? getApiErrorMessage(error, fallback); +}; + export default axios; export { axios };