diff --git a/client/jest.config.ts b/client/jest.config.ts index 7d54d6b1d..a618bce0c 100644 --- a/client/jest.config.ts +++ b/client/jest.config.ts @@ -17,7 +17,7 @@ const config: Config = { '/src/request/', '/src/constants/', '/src/errors/', - '/src/ErrorProvider.tsx', + '/src/components/', ], verbose: true, @@ -34,6 +34,7 @@ const config: Config = { '^@types/(.*)$': '/src/types/$1', '^@errors/(.*)$': '/src/errors/$1', '^@mocks/(.*)$': '/src/mocks/$1', + '\\.svg$': '/src/mocks/svg.ts', }, testEnvironmentOptions: { customExportConditions: [''], diff --git a/client/jest.setup.ts b/client/jest.setup.ts index d3ff94078..9fe320b2c 100644 --- a/client/jest.setup.ts +++ b/client/jest.setup.ts @@ -1,5 +1,6 @@ import {server} from './src/mocks/server'; import * as router from 'react-router'; +import '@testing-library/jest-dom'; // toBeInTheDocument를 인식하기 위해 @testing-library/jest-dom/extend-expect추가 beforeAll(() => { server.listen(); diff --git a/client/package-lock.json b/client/package-lock.json index ca9b099ba..34f7700fa 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^18.3.3", "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18.3.0", + "@types/testing-library__jest-dom": "^6.0.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", "cypress": "^13.13.2", @@ -5007,6 +5008,16 @@ "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", "dev": true }, + "node_modules/@types/testing-library__jest-dom": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-6.0.0.tgz", + "integrity": "sha512-bnreXCgus6IIadyHNlN/oI5FfX4dWgvGhOPvpr7zzCYDGAPIfvyIoAozMBINmhmsVuqV0cncejF2y5KC7ScqOg==", + "deprecated": "This is a stub types definition. @testing-library/jest-dom provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "@testing-library/jest-dom": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", diff --git a/client/package.json b/client/package.json index fe7f565ed..775c14c0b 100644 --- a/client/package.json +++ b/client/package.json @@ -31,6 +31,7 @@ "@types/react": "^18.3.3", "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18.3.0", + "@types/testing-library__jest-dom": "^6.0.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", "cypress": "^13.13.2", diff --git a/client/src/App.tsx b/client/src/App.tsx index 64de1e68c..e3b8e69d6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,11 +2,11 @@ import {Outlet} from 'react-router-dom'; import {HDesignProvider} from 'haengdong-design'; import {Global} from '@emotion/react'; -import {ToastProvider} from '@components/Toast/ToastProvider'; +import {ToastProvider} from '@hooks/useToast/ToastProvider'; import {GlobalStyle} from './GlobalStyle'; // import toast from 'react-simple-toasts'; -import {ErrorProvider} from './ErrorProvider'; +import {ErrorProvider} from './hooks/useError/ErrorProvider'; import UnhandledErrorBoundary from './UnhandledErrorBoundary'; const App: React.FC = () => { diff --git a/client/src/ErrorProvider.tsx b/client/src/ErrorProvider.tsx deleted file mode 100644 index c657d9337..000000000 --- a/client/src/ErrorProvider.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import {createContext, useState, useContext, useEffect, ReactNode} from 'react'; - -import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; - -// 에러 컨텍스트 생성 -interface ErrorContextType { - hasError: boolean; - errorMessage: string; - setError: (error: ServerError) => void; - clearError: (ms?: number) => void; - error: ServerError | null; -} - -const ErrorContext = createContext(undefined); - -// 에러 컨텍스트를 제공하는 프로바이더 컴포넌트 -interface ErrorProviderProps { - children: ReactNode; - callback?: (message: string) => void; -} - -export type ServerError = { - errorCode: string; - message: string; -}; - -export const ErrorProvider = ({children, callback}: ErrorProviderProps) => { - const [hasError, setHasError] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [error, setErrorState] = useState(null); - - useEffect(() => { - if (error) { - if (isUnhandledError(error.errorCode)) { - // 에러바운더리로 보내기 - - throw error; - } - - setHasError(true); - const message = SERVER_ERROR_MESSAGES[error.errorCode]; - setErrorMessage(message); - // callback(message); - } - }, [error, callback]); - - const setError = (error: ServerError) => { - setHasError(true); - setErrorMessage(''); - setErrorState(error); - }; - - const clearError = (ms: number = 0) => { - if (error === null) return; - - setTimeout(() => { - setHasError(false); - setErrorMessage(''); - setErrorState(null); - }, ms); - }; - - return ( - - {children} - - ); -}; - -// 에러 컨텍스트를 사용하는 커스텀 훅 -export const useError = (): ErrorContextType => { - const context = useContext(ErrorContext); - if (!context) { - throw new Error('useError must be used within an ErrorProvider'); - } - return context; -}; - -const isUnhandledError = (errorCode: string) => { - if (errorCode === 'INTERNAL_SERVER_ERROR') return true; - - return SERVER_ERROR_MESSAGES[errorCode] === undefined; -}; diff --git a/client/src/apis/fetcher.ts b/client/src/apis/fetcher.ts index 01c4bec7f..e87e3d395 100644 --- a/client/src/apis/fetcher.ts +++ b/client/src/apis/fetcher.ts @@ -1,4 +1,4 @@ -import {ServerError} from 'ErrorProvider'; +import {ErrorInfo} from '@hooks/useError/ErrorProvider'; import objectToQueryString from '@utils/objectToQueryString'; @@ -96,15 +96,15 @@ const errorHandler = async ({url, options, body}: ErrorHandlerProps) => { const response: Response = await fetch(url, options); if (!response.ok) { - const serverErrorBody: ServerError = await response.json(); + const serverErrorInfo: ErrorInfo = await response.json(); throw new FetchError({ status: response.status, requestBody: body, endpoint: response.url, - errorBody: serverErrorBody, - name: serverErrorBody.errorCode, - message: serverErrorBody.message || '', + errorInfo: serverErrorInfo, + name: serverErrorInfo.errorCode, + message: serverErrorInfo.message || '', method: options.method, }); } @@ -112,7 +112,7 @@ const errorHandler = async ({url, options, body}: ErrorHandlerProps) => { return response; } catch (error) { if (error instanceof Error) { - throw error; + throw error; // 그대로 FetchError || Error 인스턴스를 던집니다. } throw new Error(UNKNOWN_ERROR); diff --git a/client/src/components/Modal/SetActionModal/DeleteMemberActionModal/DeleteMemberActionModal.tsx b/client/src/components/Modal/SetActionModal/DeleteMemberActionModal/DeleteMemberActionModal.tsx index b4c24afbb..04242fad1 100644 --- a/client/src/components/Modal/SetActionModal/DeleteMemberActionModal/DeleteMemberActionModal.tsx +++ b/client/src/components/Modal/SetActionModal/DeleteMemberActionModal/DeleteMemberActionModal.tsx @@ -2,8 +2,8 @@ import type {MemberAction, MemberType} from 'types/serviceType'; import {BottomSheet, Flex, Input, Text, IconButton, FixedButton, Icon} from 'haengdong-design'; -import {useToast} from '@components/Toast/ToastProvider'; import useDeleteMemberAction from '@hooks/useDeleteMemberAction/useDeleteMemberAction'; +import {useToast} from '@hooks/useToast/useToast'; import {bottomSheetHeaderStyle, bottomSheetStyle, inputGroupStyle} from './DeleteMemberActionModal.style'; diff --git a/client/src/components/Toast/Toast.tsx b/client/src/components/Toast/Toast.tsx index d19b11ca3..edb17400d 100644 --- a/client/src/components/Toast/Toast.tsx +++ b/client/src/components/Toast/Toast.tsx @@ -51,7 +51,7 @@ const Toast = ({ }; return createPortal( -
+
diff --git a/client/src/errors/FetchError.ts b/client/src/errors/FetchError.ts index f7bf21fe9..9123a80b5 100644 --- a/client/src/errors/FetchError.ts +++ b/client/src/errors/FetchError.ts @@ -4,16 +4,16 @@ class FetchError extends Error { requestBody; status; endpoint; - errorBody; + errorInfo; method; - constructor({requestBody, status, endpoint, errorBody, method, name, message}: FetchErrorType) { - super(errorBody.errorCode); + constructor({requestBody, status, endpoint, errorInfo, method, name, message}: FetchErrorType) { + super(errorInfo.errorCode); this.requestBody = requestBody; this.status = status; this.endpoint = endpoint; - this.errorBody = errorBody; + this.errorInfo = errorInfo; this.method = method; this.name = name; this.message = message; diff --git a/client/src/hooks/useAuth/useAuth.test.tsx b/client/src/hooks/useAuth/useAuth.test.tsx index 48aaade10..130aa73f2 100644 --- a/client/src/hooks/useAuth/useAuth.test.tsx +++ b/client/src/hooks/useAuth/useAuth.test.tsx @@ -2,11 +2,13 @@ import {renderHook, waitFor} from '@testing-library/react'; import {act} from 'react'; import {MemoryRouter} from 'react-router-dom'; +import {useError} from '@hooks/useError/useError'; + import {PASSWORD_LENGTH} from '@constants/password'; import {VALID_PASSWORD_FOR_TEST, VALID_TOKEN_FOR_TEST} from '@mocks/validValueForTest'; -import {ErrorProvider, useError} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; import useAuth from './useAuth'; @@ -34,7 +36,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('TOKEN_NOT_FOUND'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('TOKEN_NOT_FOUND'); }); }); @@ -48,7 +50,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error).toBe(null); + expect(result.current.errorResult.errorInfo).toBe(null); }); }); @@ -62,7 +64,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('TOKEN_INVALID'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('TOKEN_INVALID'); }); }); @@ -76,7 +78,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('TOKEN_EXPIRED'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('TOKEN_EXPIRED'); }); }); @@ -90,7 +92,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('FORBIDDEN'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('FORBIDDEN'); }); }); }); @@ -104,7 +106,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error).toBe(null); + expect(result.current.errorResult.errorInfo).toBe(null); }); }); @@ -116,7 +118,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('EVENT_PASSWORD_FORMAT_INVALID'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('EVENT_PASSWORD_FORMAT_INVALID'); }); }); @@ -128,7 +130,7 @@ describe('useAuth', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('PASSWORD_INVALID'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('PASSWORD_INVALID'); }); }); }); diff --git a/client/src/hooks/useDeleteMemberAction/useDeleteMemberAction.test.tsx b/client/src/hooks/useDeleteMemberAction/useDeleteMemberAction.test.tsx index 9ca4d7bff..0e3ae3fdb 100644 --- a/client/src/hooks/useDeleteMemberAction/useDeleteMemberAction.test.tsx +++ b/client/src/hooks/useDeleteMemberAction/useDeleteMemberAction.test.tsx @@ -6,7 +6,7 @@ import {BillStep, MemberAction, MemberStep} from 'types/serviceType'; import stepListJson from '../../mocks/stepList.json'; import StepListProvider, {useStepList} from '../useStepList/useStepList'; -import {ErrorProvider} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; import invalidMemberStepListJson from '../../mocks/invalidMemberStepList.json'; import useDeleteMemberAction from './useDeleteMemberAction'; diff --git a/client/src/hooks/useError/ErrorProvider.tsx b/client/src/hooks/useError/ErrorProvider.tsx new file mode 100644 index 000000000..e1466cf4c --- /dev/null +++ b/client/src/hooks/useError/ErrorProvider.tsx @@ -0,0 +1,69 @@ +import {createContext, useState, useEffect, ReactNode} from 'react'; + +import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; + +// 에러 컨텍스트 생성 +export interface ErrorContextType { + clientErrorMessage: string; + setErrorInfo: (error: ErrorInfo) => void; + clearError: (ms?: number) => void; + errorInfo: ErrorInfo | null; +} + +export const ErrorContext = createContext(undefined); + +// 에러 컨텍스트를 제공하는 프로바이더 컴포넌트 +interface ErrorProviderProps { + children: ReactNode; + callback?: (message: string) => void; +} + +export type ErrorInfo = { + errorCode: string; + message: string; +}; + +export const ErrorProvider = ({children, callback}: ErrorProviderProps) => { + const [clientErrorMessage, setClientErrorMessage] = useState(''); + const [errorInfo, setErrorState] = useState(null); + + useEffect(() => { + if (errorInfo) { + if (isUnhandledError(errorInfo.errorCode)) { + // 에러바운더리로 보내기 + + throw errorInfo; + } + + const message = SERVER_ERROR_MESSAGES[errorInfo.errorCode]; + setClientErrorMessage(message); + // callback(message); + } + }, [errorInfo, callback]); + + const setErrorInfo = (error: ErrorInfo) => { + setClientErrorMessage(''); + setErrorState(error); + }; + + const clearError = (ms: number = 0) => { + if (errorInfo === null) return; + + setTimeout(() => { + setClientErrorMessage(''); + setErrorState(null); + }, ms); + }; + + return ( + + {children} + + ); +}; + +const isUnhandledError = (errorCode: string) => { + if (errorCode === 'INTERNAL_SERVER_ERROR') return true; + + return SERVER_ERROR_MESSAGES[errorCode] === undefined; +}; diff --git a/client/src/hooks/useError/useError.test.tsx b/client/src/hooks/useError/useError.test.tsx new file mode 100644 index 000000000..7c39d0e95 --- /dev/null +++ b/client/src/hooks/useError/useError.test.tsx @@ -0,0 +1,121 @@ +import {renderHook, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import {act} from 'react'; +import {HDesignProvider} from 'haengdong-design'; + +import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; + +import UnhandledErrorBoundary from '../../UnhandledErrorBoundary'; + +import {ErrorInfo, ErrorProvider} from './ErrorProvider'; +import {useError} from './useError'; + +describe('useError', () => { + const initializeProvider = () => + renderHook(() => useError(), { + wrapper: ({children}) => ( + + + + {children} + + + + ), + }); + + /** + * useError, ErrorProvider, UnhandledErrorBoundary에서 사용되는 `핸들링 가능한 에러`의 정의 + * + * : 서버에서 미리 정의한 에러 코드와 에러 메세지를 던져주는 경우 이를 `핸들링 가능한 에러`로 합니다. + * 다만 예외적으로 INTERNAL_SERVER_ERROR는 핸들링 `불가능`한 에러로 합니다. + */ + const errorCode = 'EVENT_NOT_FOUND'; + const errorInfo: ErrorInfo = {errorCode, message: '메세지입니다.'}; + const expectedClientErrorMessage = SERVER_ERROR_MESSAGES[errorCode]; + + describe('저장된 에러를 초기화한다.', () => { + it('에러 초기화 함수에 인자를 넘겨주지 않은 경우 바로 에러를 초기화한다.', async () => { + const {result} = initializeProvider(); + + await act(async () => result.current.setErrorInfo(errorInfo)); + + // 에러 메세지가 세팅되기 까지 대기 (없어도 통과하나 제대로 값이 들어간 후 초기화됨을 확인하기 위함) + await waitFor(() => { + expect(result.current.clientErrorMessage).toEqual(expectedClientErrorMessage); + }); + + await act(async () => result.current.clearError()); + + await waitFor(() => expect(result.current.errorInfo).toBe(null)); + }); + + it('저장된 에러가 없는데 초기화 함수를 호출할 경우 그냥 종료한다.', async () => { + const {result} = initializeProvider(); + + await act(async () => result.current.clearError()); + + await waitFor(() => expect(result.current.errorInfo).toBe(null)); + }); + }); + + describe('핸들링 가능한 에러', () => { + it('핸들링 가능한 에러인 경우 에러 메세지를 미리 정의된 에러 메세지로 세팅한다.', async () => { + const {result} = initializeProvider(); + + await act(async () => result.current.setErrorInfo(errorInfo)); + + await waitFor(() => expect(result.current.clientErrorMessage).toEqual(expectedClientErrorMessage)); + }); + }); + + describe('핸들링 불가능한 에러', () => { + it('에러 코드가 INTERNAL_SERVER_ERROR인 경우 핸들링 불가능한 에러로 판단하고 에러를 외부로 던진다.', async () => { + const {result} = initializeProvider(); + const errorCode = 'INTERNAL_SERVER_ERROR'; + const errorInfo: ErrorInfo = {errorCode, message: '서버 에러입니다.'}; + + await act(async () => { + try { + result.current.setErrorInfo(errorInfo); + } catch (error) { + expect(error).toBe(errorInfo); + } + }); + }); + + it('에러 코드가 UNHANDLED인 경우 핸들링 불가능한 에러로 판단하고 에러를 외부로 던진다.', async () => { + const {result} = initializeProvider(); + const errorCode = 'UNHANDLED'; + const errorInfo: ErrorInfo = {errorCode, message: '알 수 없는 에러입니다.'}; + + await act(async () => { + try { + result.current.setErrorInfo(errorInfo); + } catch (error) { + expect(error).toBe(errorInfo); + } + }); + }); + + it('에러 코드에 대응하는 에러메세지가 없는 에러인 경우 핸들링 불가능한 에러로 판단하고 에러를 외부로 던진다.', async () => { + const {result} = initializeProvider(); + const errorCode = 'something strange error...'; + const errorInfo: ErrorInfo = {errorCode, message: '정말 모르겠다.'}; + + await act(async () => { + try { + result.current.setErrorInfo(errorInfo); + } catch (error) { + expect(error).toBe(errorInfo); + } + }); + }); + }); + + it('Provider없이 useError를 사용할 경우 에러를 던진다.', () => { + expect(() => { + const _ = renderHook(() => useError()); + }).toThrow('useError must be used within an ErrorProvider'); + }); +}); diff --git a/client/src/hooks/useError/useError.tsx b/client/src/hooks/useError/useError.tsx new file mode 100644 index 000000000..ee7176233 --- /dev/null +++ b/client/src/hooks/useError/useError.tsx @@ -0,0 +1,12 @@ +import {useContext} from 'react'; + +import {ErrorContext, ErrorContextType} from './ErrorProvider'; + +// 에러 컨텍스트를 사용하는 커스텀 훅 +export const useError = (): ErrorContextType => { + const context = useContext(ErrorContext); + if (!context) { + throw new Error('useError must be used within an ErrorProvider'); + } + return context; +}; diff --git a/client/src/hooks/useEvent/useEvent.test.tsx b/client/src/hooks/useEvent/useEvent.test.tsx index b6a616b00..8e952a0db 100644 --- a/client/src/hooks/useEvent/useEvent.test.tsx +++ b/client/src/hooks/useEvent/useEvent.test.tsx @@ -2,12 +2,14 @@ import {renderHook} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import {act} from 'react'; +import {useError} from '@hooks/useError/useError'; + import {PASSWORD_LENGTH} from '@constants/password'; import {VALID_PASSWORD_FOR_TEST} from '@mocks/validValueForTest'; import {VALID_EVENT_NAME_LENGTH_IN_SERVER} from '@mocks/serverConstants'; -import {ErrorProvider, useError} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; import useEvent from './useEvent'; @@ -36,7 +38,7 @@ describe('useEvent', () => { }); await act(async () => { - expect(result.current.errorResult.error).toBe(null); + expect(result.current.errorResult.errorInfo).toBe(null); }); }); @@ -48,7 +50,7 @@ describe('useEvent', () => { }); await act(async () => { - expect(result.current.errorResult.error?.errorCode).toBe('EVENT_NAME_LENGTH_INVALID'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('EVENT_NAME_LENGTH_INVALID'); }); }); @@ -60,7 +62,7 @@ describe('useEvent', () => { }); await act(async () => { - expect(result.current.errorResult.error?.errorCode).toBe('EVENT_PASSWORD_FORMAT_INVALID'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('EVENT_PASSWORD_FORMAT_INVALID'); }); }); }); diff --git a/client/src/hooks/useFetch/useFetch.test.tsx b/client/src/hooks/useFetch/useFetch.test.tsx index 253d60b6e..b4748125a 100644 --- a/client/src/hooks/useFetch/useFetch.test.tsx +++ b/client/src/hooks/useFetch/useFetch.test.tsx @@ -3,6 +3,7 @@ import {MemoryRouter} from 'react-router-dom'; import {act} from 'react'; import FetchError from '@errors/FetchError'; +import {useError} from '@hooks/useError/useError'; import {requestPostWithoutResponse} from '@apis/fetcher'; @@ -10,7 +11,7 @@ import {captureError} from '@utils/captureError'; import {UNKNOWN_ERROR} from '@constants/errorMessage'; -import {ErrorProvider, useError} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; import {useFetch} from './useFetch'; @@ -63,7 +64,7 @@ describe('useFetch', () => { it('FetchError가 발생하면 해당 에러의 errorBody를 사용해 상태를 저장한다.', async () => { const {result} = initializeProvider(); const fetchError = new FetchError({ - errorBody: {errorCode: 'UNHANDLED', message: 'Fetch error occurred'}, + errorInfo: {errorCode: 'UNHANDLED', message: 'Fetch error occurred'}, name: 'UNHANDLED', message: 'Fetch error occurred', requestBody: '', @@ -78,8 +79,8 @@ describe('useFetch', () => { }); await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('UNHANDLED'); - expect(result.current.errorResult.error?.message).toBe('Fetch error occurred'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('UNHANDLED'); + expect(result.current.errorResult.errorInfo?.message).toBe('Fetch error occurred'); }); }); @@ -97,8 +98,8 @@ describe('useFetch', () => { } await waitFor(() => { - expect(result.current.errorResult.error?.errorCode).toBe('Error'); - expect(result.current.errorResult.error?.message).toBe('일반 에러 발생'); + expect(result.current.errorResult.errorInfo?.errorCode).toBe('Error'); + expect(result.current.errorResult.errorInfo?.message).toBe('일반 에러 발생'); }); }); diff --git a/client/src/hooks/useFetch/useFetch.ts b/client/src/hooks/useFetch/useFetch.ts index 2d034fa5e..35bf18a33 100644 --- a/client/src/hooks/useFetch/useFetch.ts +++ b/client/src/hooks/useFetch/useFetch.ts @@ -2,14 +2,13 @@ import {useState} from 'react'; import {useNavigate} from 'react-router-dom'; import FetchError from '@errors/FetchError'; +import {useError} from '@hooks/useError/useError'; import {captureError} from '@utils/captureError'; import getEventIdByUrl from '@utils/getEventIdByUrl'; import {UNKNOWN_ERROR} from '@constants/errorMessage'; -import {useError} from '../../ErrorProvider'; - type FetchProps = { queryFunction: () => Promise; onSuccess?: () => void; @@ -17,7 +16,7 @@ type FetchProps = { }; export const useFetch = () => { - const {setError, clearError} = useError(); + const {setErrorInfo, clearError} = useError(); const [loading, setLoading] = useState(false); const navigate = useNavigate(); const eventId = getEventIdByUrl(); @@ -36,10 +35,10 @@ export const useFetch = () => { return result; } catch (error) { if (error instanceof Error) { - const errorBody = - error instanceof FetchError ? error.errorBody : {errorCode: error.name, message: error.message}; + const errorInfo = + error instanceof FetchError ? error.errorInfo : {errorCode: error.name, message: error.message}; - setError(errorBody); + setErrorInfo(errorInfo); if (onError) { onError(); @@ -47,7 +46,7 @@ export const useFetch = () => { captureError(error, navigate, eventId); } else { - setError({errorCode: UNKNOWN_ERROR, message: JSON.stringify(error)}); + setErrorInfo({errorCode: UNKNOWN_ERROR, message: JSON.stringify(error)}); captureError(new Error(UNKNOWN_ERROR), navigate, eventId); // 에러를 throw 해 에러 바운더리로 보냅니다. 따라서 에러 이름은 중요하지 않음 diff --git a/client/src/hooks/useSearchMemberReportList/useSearchMemberReportList.test.tsx b/client/src/hooks/useSearchMemberReportList/useSearchMemberReportList.test.tsx index 2688a9a04..667f0bfb6 100644 --- a/client/src/hooks/useSearchMemberReportList/useSearchMemberReportList.test.tsx +++ b/client/src/hooks/useSearchMemberReportList/useSearchMemberReportList.test.tsx @@ -2,7 +2,7 @@ import {renderHook, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import reportListJson from '../../mocks/reportList.json'; -import {ErrorProvider} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; import useSearchMemberReportList from './useSearchMemberReportList'; diff --git a/client/src/hooks/useStepList/useStepList.test.tsx b/client/src/hooks/useStepList/useStepList.test.tsx index 81963b73a..ae4cca978 100644 --- a/client/src/hooks/useStepList/useStepList.test.tsx +++ b/client/src/hooks/useStepList/useStepList.test.tsx @@ -6,7 +6,7 @@ import {Bill} from 'types/serviceType'; import StepListProvider, {useStepList} from '../useStepList/useStepList'; import stepListJson from '../../mocks/stepList.json'; -import {ErrorProvider} from '../../ErrorProvider'; +import {ErrorProvider} from '../useError/ErrorProvider'; const stepListMockData = stepListJson; @@ -105,7 +105,7 @@ describe('useStepList', () => { it('provider안에서 호출되지 않으면 에러를 던진다.', () => { expect(() => { - const {result} = renderHook(() => useStepList()); + const _ = renderHook(() => useStepList()); }).toThrow('useStepList는 StepListProvider 내에서 사용되어야 합니다.'); }); }); diff --git a/client/src/components/Toast/ToastProvider.tsx b/client/src/hooks/useToast/ToastProvider.tsx similarity index 73% rename from client/src/components/Toast/ToastProvider.tsx rename to client/src/hooks/useToast/ToastProvider.tsx index d842a3a08..eed006967 100644 --- a/client/src/components/Toast/ToastProvider.tsx +++ b/client/src/hooks/useToast/ToastProvider.tsx @@ -1,12 +1,12 @@ /** @jsxImportSource @emotion/react */ import {createContext, useContext, useEffect, useState} from 'react'; -import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; +import {useError} from '@hooks/useError/useError'; -import {useError} from '../../ErrorProvider'; +import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; -import {ToastProps} from './Toast.type'; -import Toast from './Toast'; +import {ToastProps} from '../../components/Toast/Toast.type'; +import Toast from '../../components/Toast/Toast'; export const ToastContext = createContext(null); @@ -21,9 +21,9 @@ type ShowToast = ToastProps & { isAlwaysOn?: boolean; }; -const ToastProvider = ({children}: React.PropsWithChildren) => { +export const ToastProvider = ({children}: React.PropsWithChildren) => { const [currentToast, setCurrentToast] = useState(null); - const {hasError, errorMessage, clearError} = useError(); + const {errorInfo, clearError, clientErrorMessage} = useError(); const showToast = ({showingTime = DEFAULT_TIME, isAlwaysOn = false, ...toastProps}: ShowToast) => { setCurrentToast({showingTime, isAlwaysOn, ...toastProps}); @@ -34,9 +34,9 @@ const ToastProvider = ({children}: React.PropsWithChildren) => { }; useEffect(() => { - if (hasError) { + if (errorInfo !== null) { showToast({ - message: errorMessage || SERVER_ERROR_MESSAGES.UNHANDLED, + message: clientErrorMessage || SERVER_ERROR_MESSAGES.UNHANDLED, showingTime: DEFAULT_TIME, // TODO: (@weadie) 나중에 토스트 프로바이더를 제거한 토스트를 만들 것이기 때문에 많이 리펙터링 안함 isAlwaysOn: false, position: 'bottom', @@ -47,7 +47,7 @@ const ToastProvider = ({children}: React.PropsWithChildren) => { clearError(DEFAULT_TIME); } - }, [errorMessage, hasError]); + }, [errorInfo, clientErrorMessage]); useEffect(() => { if (!currentToast) return; @@ -70,15 +70,3 @@ const ToastProvider = ({children}: React.PropsWithChildren) => { ); }; - -const useToast = () => { - const context = useContext(ToastContext); - - if (!context) { - throw new Error('useToast는 ToastProvider 내에서 사용되어야 합니다.'); - } - - return context; -}; - -export {ToastProvider, useToast}; diff --git a/client/src/hooks/useToast/useToast.test.tsx b/client/src/hooks/useToast/useToast.test.tsx new file mode 100644 index 000000000..6f9522a24 --- /dev/null +++ b/client/src/hooks/useToast/useToast.test.tsx @@ -0,0 +1,99 @@ +import {render, screen, waitFor} from '@testing-library/react'; +import {act, ReactNode} from 'react'; +import {HDesignProvider} from 'haengdong-design'; + +import {useError} from '@hooks/useError/useError'; + +import {SERVER_ERROR_MESSAGES} from '@constants/errorMessage'; + +import UnhandledErrorBoundary from '../../UnhandledErrorBoundary'; +import {ErrorInfo, ErrorProvider} from '../../hooks/useError/ErrorProvider'; // useError 경로에 맞게 설정 + +import {ToastProvider} from './ToastProvider'; // 위 코드에 해당하는 ToastProvider 경로 + +// 테스트용 헬퍼 컴포넌트 +const TestComponent = ({errorInfo}: {errorInfo: ErrorInfo}) => { + const {setErrorInfo} = useError(); + + // 테스트에서 직접 에러를 설정합니다. + const triggerError = () => { + setErrorInfo(errorInfo); + }; + + return ; +}; + +const setup = (ui: ReactNode) => + render( + + + + {ui} + + + , + ); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('useToast', () => { + describe('error의 경우 자동으로 토스트를 띄워준다.', () => { + it('핸들링 가능한 에러인 경우 토스트가 뜬다.', async () => { + const errorCode = 'ACTION_NOT_FOUND'; + + setup( + , + ); + const errorMessage = SERVER_ERROR_MESSAGES[errorCode]; + + act(() => { + // 에러 트리거 버튼을 클릭 + screen.getByText('Trigger Error').click(); + }); + + // 토스트가 표시되는지 확인 + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + // 타이머가 지나서 토스트가 사라지는지 확인 + jest.runAllTimers(); // Jest의 타이머를 실행 + await waitFor(() => { + expect(screen.queryByText(errorMessage)).not.toBeInTheDocument(); + }); + }); + + it('핸들링 불가능한 에러인 경우 토스트가 안뜬다.', async () => { + const errorCode = '핸들링이 안되는 에러 코드'; + + setup( + , + ); + + act(() => { + // 에러 트리거 버튼을 클릭 + screen.getByText('Trigger Error').click(); + }); + + await waitFor(() => { + expect(document.getElementById('toast')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/client/src/hooks/useToast/useToast.tsx b/client/src/hooks/useToast/useToast.tsx new file mode 100644 index 000000000..189847405 --- /dev/null +++ b/client/src/hooks/useToast/useToast.tsx @@ -0,0 +1,13 @@ +import {useContext} from 'react'; + +import {ToastContext} from './ToastProvider'; + +export const useToast = () => { + const context = useContext(ToastContext); + + if (!context) { + throw new Error('useToast는 ToastProvider 내에서 사용되어야 합니다.'); + } + + return context; +}; diff --git a/client/src/mocks/svg.ts b/client/src/mocks/svg.ts new file mode 100644 index 000000000..ffe2050a0 --- /dev/null +++ b/client/src/mocks/svg.ts @@ -0,0 +1,2 @@ +export default 'SvgrURL'; +export const ReactComponent = 'div'; diff --git a/client/src/pages/CreateEventPage/CompleteCreateEventPage.tsx b/client/src/pages/CreateEventPage/CompleteCreateEventPage.tsx index d33f5b6e7..fae728ae4 100644 --- a/client/src/pages/CreateEventPage/CompleteCreateEventPage.tsx +++ b/client/src/pages/CreateEventPage/CompleteCreateEventPage.tsx @@ -3,7 +3,7 @@ import {Button, FixedButton, Flex, Input, MainLayout, Text, Title, TopNav} from import {CopyToClipboard} from 'react-copy-to-clipboard'; import {css} from '@emotion/react'; -import {useToast} from '@components/Toast/ToastProvider'; +import {useToast} from '@hooks/useToast/useToast'; import getEventPageUrlByEnvironment from '@utils/getEventPageUrlByEnvironment'; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index 5aa61a570..0c67454ac 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -3,9 +3,9 @@ import {Outlet, useMatch} from 'react-router-dom'; import {useEffect, useState} from 'react'; import CopyToClipboard from 'react-copy-to-clipboard'; -import {useToast} from '@components/Toast/ToastProvider'; import {requestGetEventName} from '@apis/request/event'; import StepListProvider from '@hooks/useStepList/useStepList'; +import {useToast} from '@hooks/useToast/useToast'; import useNavSwitch from '@hooks/useNavSwitch'; diff --git a/client/src/types/fetchErrorType.ts b/client/src/types/fetchErrorType.ts index 2ab327e19..664fa368b 100644 --- a/client/src/types/fetchErrorType.ts +++ b/client/src/types/fetchErrorType.ts @@ -1,4 +1,4 @@ -import {ServerError} from 'ErrorProvider'; +import {ErrorInfo} from '@hooks/useError/ErrorProvider'; import {Method} from '@apis/fetcher'; @@ -6,6 +6,6 @@ export type FetchErrorType = Error & { requestBody: string; status: number; endpoint: string; - errorBody: ServerError; + errorInfo: ErrorInfo; method: Method; }; diff --git a/client/src/utils/captureError.ts b/client/src/utils/captureError.ts index d0af34729..ce70da972 100644 --- a/client/src/utils/captureError.ts +++ b/client/src/utils/captureError.ts @@ -1,7 +1,7 @@ import {NavigateFunction} from 'react-router-dom'; import FetchError from '@errors/FetchError'; -import {ServerError} from 'ErrorProvider'; +import {ErrorInfo} from '@hooks/useError/ErrorProvider'; import {ROUTER_URLS} from '@constants/routerUrls'; @@ -11,47 +11,47 @@ export const captureError = async (error: Error, navigate: NavigateFunction, eve // prod 환경에서만 Sentry capture 실행 if (process.env.NODE_ENV !== 'production') return; - const errorBody: ServerError = - error instanceof FetchError ? error.errorBody : {message: error.message, errorCode: error.name}; + const errorInfo: ErrorInfo = + error instanceof FetchError ? error.errorInfo : {message: error.message, errorCode: error.name}; - switch (errorBody?.errorCode) { + switch (errorInfo?.errorCode) { case 'INTERNAL_SERVER_ERROR': - sendLogToSentry({error, errorBody, level: 'fatal'}); + sendLogToSentry({error, errorInfo, level: 'fatal'}); break; case 'FORBIDDEN': - sendLogToSentry({error, errorBody}); + sendLogToSentry({error, errorInfo}); navigate(`${ROUTER_URLS.event}/${eventId}/login`); break; case 'TOKEN_INVALID': - sendLogToSentry({error, errorBody}); + sendLogToSentry({error, errorInfo}); navigate(`${ROUTER_URLS.event}/${eventId}/login`); break; case 'TOKEN_EXPIRED': - sendLogToSentry({error, errorBody}); + sendLogToSentry({error, errorInfo}); navigate(`${ROUTER_URLS.event}/${eventId}/login`); break; case 'TOKEN_NOT_FOUND': - sendLogToSentry({error, errorBody}); + sendLogToSentry({error, errorInfo}); navigate(`${ROUTER_URLS.event}/${eventId}/login`); break; // 비밀 번호를 까먹는 사람이 얼마나 많은 지 추측하기 위함 case 'PASSWORD_INVALID': - sendLogToSentry({error, errorBody, level: 'debug'}); + sendLogToSentry({error, errorInfo, level: 'debug'}); navigate(`${ROUTER_URLS.event}/${eventId}/login`); break; // 1천만원 이상 입력하는 사람이 얼마나 많은 지 추측하기 위함 case 'BILL_ACTION_PRICE_INVALID': - sendLogToSentry({error, errorBody, level: 'debug'}); + sendLogToSentry({error, errorInfo, level: 'debug'}); break; default: - sendLogToSentry({error, errorBody, level: 'fatal'}); + sendLogToSentry({error, errorInfo, level: 'fatal'}); break; } }; diff --git a/client/src/utils/sendLogToSentry.ts b/client/src/utils/sendLogToSentry.ts index 165cf323b..d93e526a4 100644 --- a/client/src/utils/sendLogToSentry.ts +++ b/client/src/utils/sendLogToSentry.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/react'; -import {ServerError} from 'ErrorProvider'; +import {ErrorInfo} from '@hooks/useError/ErrorProvider'; import {UNKNOWN_ERROR} from '@constants/errorMessage'; @@ -21,12 +21,12 @@ type SentryLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log'; type SendLogToSentry = { level?: SentryLevel; error: Error; - errorBody: ServerError; + errorInfo: ErrorInfo; }; -const sendLogToSentry = ({level = 'error', error, errorBody}: SendLogToSentry) => { +const sendLogToSentry = ({level = 'error', error, errorInfo}: SendLogToSentry) => { Sentry.withScope(scope => { - const {errorCode, message} = errorBody; + const {errorCode, message} = errorInfo; scope.setLevel(level); scope.setTag('environment', process.env.NODE_ENV); diff --git a/client/tsconfig.json b/client/tsconfig.json index 7c749b6f5..8b7b30d14 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -20,7 +20,7 @@ // "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": ["jest", "node", "cypress"], + "types": ["jest", "@testing-library/jest-dom", "node", "cypress"], "esModuleInterop": true, "strictNullChecks": true, "strictFunctionTypes": true,