diff --git a/.env.sample b/.env.sample index 9e4bd3e..fb1e5e4 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,2 @@ -NEXT_PUBLIC_BASE_URL= +NEXT_PUBLIC_BASE_URL=<'server url here'> +NEXT_PUBLIC_ABORT_MS=<'abort time(ms) for fetch here'> diff --git a/eslint.config.mjs b/eslint.config.mjs index 06e24d7..663446a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,7 @@ export default [ '@typescript-eslint/promise-function-async': 'error', '@typescript-eslint/consistent-type-assertions': 'error', '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-explicit-any': 'warn', }, languageOptions: { parserOptions: { diff --git a/src/__test__/login.test.tsx b/src/__test__/login.test.tsx index c3a1cd2..1bc2334 100644 --- a/src/__test__/login.test.tsx +++ b/src/__test__/login.test.tsx @@ -1,10 +1,11 @@ -import { act, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import Page from '@/app/(login)/page'; -import fetchMock from 'jest-fetch-mock'; -import { renderWithQueryClient } from '@/utils/test-util'; +import { act, screen } from '@testing-library/react'; import { ToastContainer } from 'react-toastify'; import { useRouter } from 'next/navigation'; +import fetchMock from 'jest-fetch-mock'; +import { renderWithQueryClient } from '@/utils'; +import { TimeoutError } from '@/errors'; +import { Login } from '@/app'; jest.mock('next/navigation', () => ({ useRouter: jest.fn(), @@ -26,7 +27,7 @@ const renderPage = () => { renderWithQueryClient( <> - + , ); }; @@ -58,25 +59,24 @@ describe('로그인 화면에서', () => { }); describe('API 요청에서', () => { - // it('서버가 응답하지 않으면 오류 토스트가 표기된다', async () => { - // renderPage(); + it('서버가 응답하지 않으면 응답 없음 토스트가 표시된다', async () => { + renderPage(); - // fetchMock.mockAbortOnce(); + fetchMock.mockRejectedValueOnce(new TimeoutError()); - // const { buttonEl, accessInputEl, refreshInputEl } = getElements(); + const { buttonEl, accessInputEl, refreshInputEl } = getElements(); - // await userEvent.type(accessInputEl, 'invalid_access'); - // await userEvent.type(refreshInputEl, 'invalid_refresh'); - // await act(async () => buttonEl.click()); + await userEvent.type(accessInputEl, 'invalid_access'); + await userEvent.type(refreshInputEl, 'invalid_refresh'); + await act(async () => buttonEl.click()); - // const toastEl = screen.getByText('유효하지 않은 토큰 (404)'); - // expect(toastEl).toBeInTheDocument(); - // }); + const toastEl = screen.getByText('잠시 후 다시 시도해 주세요'); + expect(toastEl).not.toBeUndefined(); + }); it('액세스 토큰이 비정상적이면 오류 토스트가 표기된다', async () => { renderPage(); - - fetchMock.mockRejectOnce(new Error()); + fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as Response); const { buttonEl, accessInputEl, refreshInputEl } = getElements(); @@ -84,11 +84,11 @@ describe('로그인 화면에서', () => { await userEvent.type(refreshInputEl, 'invalid_refresh'); await act(async () => buttonEl.click()); - const toastEl = screen.getByText('유효하지 않은 토큰 (404)'); - expect(toastEl).toBeInTheDocument(); + const toastEl = screen.getByText('일치하는 계정을 찾을 수 없습니다'); + expect(toastEl).not.toBeUndefined(); }); - it('액세스 토큰이 정상적이면 페이지를 대시보드로 이동시킨다', async () => { + it('요청이 성공하면 페이지를 대시보드로 이동시킨다', async () => { renderPage(); const replace = jest.fn(); @@ -97,7 +97,6 @@ describe('로그인 화면에서', () => { fetchMock.mockResolvedValueOnce({ ok: true, status: 200, - json: () => ({}), } as Response); const { buttonEl, accessInputEl, refreshInputEl } = getElements(); diff --git a/src/api/index.ts b/src/api/index.ts index c546001..d4af5fa 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,18 +1,63 @@ -import returnFetch from 'return-fetch'; +import returnFetch, { FetchArgs } from 'return-fetch'; +import { ServerNotRespondingError } from '@/errors'; const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; +const ABORT_MS = Number(process.env.NEXT_PUBLIC_ABORT_MS); + +if (Number.isNaN(ABORT_MS)) { + throw new Error('ABORT_MS가 ENV에서 설정되지 않았습니다'); +} if (!BASE_URL) { - throw new Error('BASE_URL가 ENV에서 설정되지 않았습니다.'); + throw new Error('BASE_URL이 ENV에서 설정되지 않았습니다.'); } -export const instance = returnFetch({ +type ErrorObject = Record; + +const abortPolyfill = (ms: number) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; +}; + +const fetch = returnFetch({ baseUrl: BASE_URL, headers: { Accept: 'application/json' }, interceptors: { response: async (response) => { - if (!response.ok) throw response; - return response; + if (!response.ok) { + throw response; + } + return { + ...response, + body: response?.text ? JSON.parse(await response?.text()) : {}, + }; }, }, }); + +export const instance = async ( + input: URL | RequestInfo, + init?: Omit, 'body'> & { body: object }, + error?: ErrorObject, +) => { + try { + const data = await fetch(input, { + ...init, + body: init?.body ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout + ? AbortSignal.timeout(ABORT_MS) + : abortPolyfill(ABORT_MS), + }); + + return data as Awaited>; + } catch (err: any) { + if ((err as Error).name === 'TimeoutError') + throw new ServerNotRespondingError(); + else { + if (!error || !(error && error[`${(err as Response).status}`])) + throw new Error(`서버에서 Z 오류가 발생했습니다. (${err.name})`); + throw error[`${(err as Response).status}`]; + } + } +}; diff --git a/src/app/(login)/Content.tsx b/src/app/(login)/Content.tsx index 18cd2e8..83ad765 100644 --- a/src/app/(login)/Content.tsx +++ b/src/app/(login)/Content.tsx @@ -1,13 +1,13 @@ 'use client'; -import { Button, Input } from '@/components'; -import { useForm } from 'react-hook-form'; import { useMutation } from '@tanstack/react-query'; -import { instance } from '../../api'; import { useRouter } from 'next/navigation'; -import { toast } from 'react-toastify'; +import { useForm } from 'react-hook-form'; +import { Button, Input } from '@/components'; +import { NotFoundError } from '@/errors'; +import { instance } from '@/api'; -interface formVo { +interface FormVo { access_token: string; refresh_token: string; } @@ -19,51 +19,60 @@ export const Content = () => { register, handleSubmit, formState: { isValid }, - } = useForm({ mode: 'onChange' }); + } = useForm({ mode: 'onChange' }); const { mutate } = useMutation({ - mutationFn: async (data: formVo) => - await instance('/login', { - method: 'POST', - headers: { - cookie: `access_token=${data.access_token};refresh_token=${data.refresh_token}`, + mutationFn: async (body: FormVo) => + await instance( + '/login', + { method: 'POST', body }, + { + '404': new NotFoundError( + '일치하는 계정을 찾을 수 없습니다', + 'CannotFindAccount', + ), }, - }), + ), onSuccess: () => replace('/main'), - onError: (res: Response) => { - toast.error(`${res.statusText} (${res.status})`); - }, }); - const onSubmit = (data: formVo) => { + const onSubmit = (data: FormVo) => { mutate(data); }; return ( -
+

Velog Dashboard

-
-
+ ); }; diff --git a/src/app/(login)/index.ts b/src/app/(login)/index.ts new file mode 100644 index 0000000..8d7561b --- /dev/null +++ b/src/app/(login)/index.ts @@ -0,0 +1 @@ +export { default as Login } from './page'; diff --git a/src/app/(login)/page.tsx b/src/app/(login)/page.tsx index d76e16c..352cef2 100644 --- a/src/app/(login)/page.tsx +++ b/src/app/(login)/page.tsx @@ -1,5 +1,5 @@ -import { Content } from './Content'; import { Metadata } from 'next'; +import { Content } from './Content'; export const metadata: Metadata = { title: '로그인', diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..c632f97 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1 @@ +export * from './(login)'; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 763ebb5..23210ff 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,9 @@ import { ToastContainer } from 'react-toastify'; import { Noto_Sans_KR } from 'next/font/google'; +import 'react-toastify/dist/ReactToastify.css'; import type { Metadata } from 'next'; +import { QueryProvider } from '@/components'; import './globals.css'; -import { QueryProvider } from '@/utils/QueryProvider'; -import 'react-toastify/dist/ReactToastify.css'; export const metadata: Metadata = { title: 'Velog Dashboard', diff --git a/src/app/main/page.tsx b/src/app/main/page.tsx index 28bdaf4..2852e49 100644 --- a/src/app/main/page.tsx +++ b/src/app/main/page.tsx @@ -1,5 +1,5 @@ -import { Content } from './Content'; import type { Metadata } from 'next'; +import { Content } from './Content'; export const metadata: Metadata = { title: '대시보드', diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 7bcd29e..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return
; -} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 8fb73b0..2cf6fd1 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,18 +1,21 @@ -import { HTMLProps } from 'react'; -import { sizeStyle } from './size'; +import { ButtonHTMLAttributes, DetailedHTMLProps } from 'react'; +import { sizeStyle, sizeStyleType } from './size'; -interface IProp extends Omit, 'size'> { - form?: 'large' | 'small'; - size: 'large' | 'medium' | 'small'; - type?: 'submit' | 'reset' | 'button'; +interface IProp + extends DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement + > { + form?: keyof typeof formStyle; + size: sizeStyleType; } const formStyle = { - large: 'h-[55px] rounded-sm', - small: 'pl-[20px] pr-[20px] w-fit h-8 rounded-[4px]', + LARGE: 'h-[55px] rounded-sm', + SMALL: 'pl-[20px] pr-[20px] w-fit h-8 rounded-[4px]', }; -export const Button = ({ form = 'small', size, children, ...rest }: IProp) => ( +export const Button = ({ form = 'SMALL', size, children, ...rest }: IProp) => (