From 7142c9bbe39457ddece594b1480f0640a6a2fb4a Mon Sep 17 00:00:00 2001 From: yuni Date: Tue, 23 Jul 2024 01:53:34 +0900 Subject: [PATCH 01/43] =?UTF-8?q?docs:=20=EC=A0=84=EC=B2=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세부 기능요구사항은 각 스텝마다 정리할 예정 --- README.md | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3ee5a24..0e1cd935 100644 --- a/README.md +++ b/README.md @@ -1 +1,43 @@ -# 카카오 테크 캠퍼스 - 프론트엔드 카카오 선물하기 편 +# React Product Login + +## 개요 + +본 저장소는 5주차 과제 (2024-07-22 ~ 2024-07-16)를 위한 로그인 및 관심목록 구현을 담고 있습니다. 상세한 학습 내용은 [Notion 노트](https://www.notion.so/TIL-FE-25dbeb894e884b889eca0fa3e4e13904)에서 확인할 수 있습니다. + +--- + +## 0단계 - 기본 코드 준비 + +## 1단계 - Form 부분 테스트 코드 작성하기 + +- Jest와 React Testing Library를 사용하여 테스트 기반 환경을 구축합니다. +- MSW를 사용하여 Mock API가 동작하도록 합니다. (상세 API / 옵션 API) +- 단위 테스트가 필요하다면 단위 테스트 코드를 작성합니다. +- 상품 상세 페이지와 관련된 통합 테스트 코드를 작성합니다. +- 결제하기 페이지의 Form과 관련된 통합 테스트 코드를 작성합니다. +- 현금영수증 Checkbox가 false인 경우 현금영수증 종류, 현금영수증 번호 field가 비활성화 되어있는지 확인하는 테스트 코드를 작성합니다. (true인 경우 현금영수증 종류, 번호 field에 값이 입력되어야 합니다.) +- form의 validation 로직이 정상 동작하는지 확인하는 테스트 코드를 작성합니다. +- 본인만의 기준으로 일관된 코드를 작성합니다. +- 기능 단위로 나누어 커밋을 합니다. + +## 2단계 - 로그인, 관심 상품 등록 / 삭제, 관심 목록 구현 + +- 로그인 기능을 구현합니다. +- 회원가입 화면을 만들고, 회원가입 기능이 동작하도록 구현합니다. (회원가입을 하면 로그인이 되도록 합니다.) + - 회원가입 버튼은 로그인 화면 하단에 배치합니다. 로그인 화면을 그대로 사용해도 괜찮습니다. +- 상품 상세 페이지에서 관심 등록 버튼을 만듭니다. + - 상품 상세 페이지에서 관심 버튼을 클릭했을 때 관심 추가가 동작하도록 합니다. + - 관심 등록 성공 시 Alert로 "관심 등록 완료" 메시지를 노출합니다. +- 마이 페이지에서 관심 목록 리스트를 만듭니다. + - 관심 목록 리스트는 Chakra UI를 사용하여 자유롭게 만듭니다. + - 관심 목록 API는 카카오테크 선물하기 API 노션의 response 데이터를 사용합니다. + - 관심 목록 리스트에서 관심 삭제가 가능하도록 합니다. + - 관심 삭제 시 목록에서 사라집니다. +- 본인만의 기준으로 일관된 코드를 작성합니다. +- 기능 단위로 나누어 커밋을 합니다. + +## 3단계 - 질문의 답변을 README에 작성 + +- **질문 1**: Test code를 작성해보면서 좋았던 점과 아쉬웠던 점에 대해 말해주세요. +- **질문 2**: 스스로 생각했을 때 좋은 컴포넌트란 무엇인지 본인만의 기준을 세우고 설명해 주세요. +- **질문 3**: 스스로 생각했을 때 공통 컴포넌트를 만들 때 가장 중요한 요소 2개를 선택하고 이유와 함께 설명해주세요. \ No newline at end of file From 4f5ac5d1655c4097f9f14261b93b56b15022afa2 Mon Sep 17 00:00:00 2001 From: yuni Date: Tue, 23 Jul 2024 01:54:19 +0900 Subject: [PATCH 02/43] =?UTF-8?q?docs:=20step0=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 0e1cd935..d4831e77 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ ## 0단계 - 기본 코드 준비 +- [x] 기본 코드 준비 + +--- + ## 1단계 - Form 부분 테스트 코드 작성하기 - Jest와 React Testing Library를 사용하여 테스트 기반 환경을 구축합니다. @@ -20,6 +24,8 @@ - 본인만의 기준으로 일관된 코드를 작성합니다. - 기능 단위로 나누어 커밋을 합니다. +--- + ## 2단계 - 로그인, 관심 상품 등록 / 삭제, 관심 목록 구현 - 로그인 기능을 구현합니다. @@ -36,6 +42,8 @@ - 본인만의 기준으로 일관된 코드를 작성합니다. - 기능 단위로 나누어 커밋을 합니다. +--- + ## 3단계 - 질문의 답변을 README에 작성 - **질문 1**: Test code를 작성해보면서 좋았던 점과 아쉬웠던 점에 대해 말해주세요. From 98f0be9874575a22af078fe5ed689451e5f06719 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:04:19 +0900 Subject: [PATCH 03/43] =?UTF-8?q?docs:=20step1=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d4831e77..43dfb25c 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,39 @@ ## 1단계 - Form 부분 테스트 코드 작성하기 -- Jest와 React Testing Library를 사용하여 테스트 기반 환경을 구축합니다. -- MSW를 사용하여 Mock API가 동작하도록 합니다. (상세 API / 옵션 API) -- 단위 테스트가 필요하다면 단위 테스트 코드를 작성합니다. -- 상품 상세 페이지와 관련된 통합 테스트 코드를 작성합니다. -- 결제하기 페이지의 Form과 관련된 통합 테스트 코드를 작성합니다. -- 현금영수증 Checkbox가 false인 경우 현금영수증 종류, 현금영수증 번호 field가 비활성화 되어있는지 확인하는 테스트 코드를 작성합니다. (true인 경우 현금영수증 종류, 번호 field에 값이 입력되어야 합니다.) -- form의 validation 로직이 정상 동작하는지 확인하는 테스트 코드를 작성합니다. -- 본인만의 기준으로 일관된 코드를 작성합니다. -- 기능 단위로 나누어 커밋을 합니다. +### 테스트 기반 환경 구축 + +- Jest와 React Testing Library를 사용하여 테스트 환경을 설정 + - [ ] 필요한 패키지 설치 + - [ ] Jest 설정 파일 추가 + - [ ] 테스트 설정 파일 추가 + +### MSW를 사용하여 Mock API 설정 + +- [ ] Mock API 서버 설정 +- [ ] 상세 API 및 옵션 API 엔드포인트 추가 +- [ ] `msw` 설정 파일 작성 + +### 단위 테스트 작성 + +- [ ] 컴포넌트 별 단위 테스트 작성 +- [ ] 훅(hooks) 단위 테스트 작성 + +### 통합 테스트 작성 + +- 상품 상세 페이지 + - [ ] 상품 상세 정보 로딩 테스트 + - [ ] 옵션 선택 테스트 + +- 결제하기 페이지 + - [ ] 입력 필드 테스트 + - [ ] 버튼 클릭 테스트 + - 현금영수증 Checkbox가 `false`인 경우 현금영수증 종류, 현금영수증 번호 필드가 비활성화 되어있는지 확인하는 테스트 코드 작성 + - [ ] Checkbox 상태에 따른 필드 활성화/비활성화 테스트 + - [ ] Checkbox가 `true`인 경우 필드 값 입력 테스트 + - Form의 validation 로직이 정상 동작하는지 확인하는 테스트 코드 작성 + - [ ] 필수 입력 필드 검사 + - [ ] 입력 값 형식 검사 --- From e13b0c02bef2d1e1e16865a8da5e78750f9ebdbe Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:11:08 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat:=20MSW=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=EC=84=B8=20=EB=B0=8F=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=20API=20=EB=AA=A8=ED=82=B9=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 API 관련 productDetail.mock.ts 파일 추가 - 제품 상세 정보를 모킹하는 핸들러 정의 - 옵션 API 관련 productOptions.mock.ts 파일 추가 - 제품 옵션 정보를 모킹하는 핸들러 정의 - MSW 설정 파일 setupMockWorker.ts 업데이트 - productDetail.mock.ts와 productOptions.mock.ts 핸들러를 MSW 설정에 추가 - 기존 categoriesMockHandler와 productsMockHandler와 함께 worker 설정 --- src/api/hooks/productDetail.mock.ts | 0 src/api/hooks/productOptions.mock.ts | 0 src/mocks/browser.ts | 9 ++++++++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/api/hooks/productDetail.mock.ts create mode 100644 src/api/hooks/productOptions.mock.ts diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index 7f2332e9..8ce5e9e0 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -2,5 +2,12 @@ import { setupWorker } from 'msw'; import { categoriesMockHandler } from '@/api/hooks/categories.mock'; import { productsMockHandler } from '@/api/hooks/products.mock'; +import { productDetailMockHandler } from '@/api/hooks/productDetail.mock'; +import { productOptionsMockHandler } from './productOptions.mock'; -export const worker = setupWorker(...categoriesMockHandler, ...productsMockHandler); +export const worker = setupWorker( + ...categoriesMockHandler, + ...productsMockHandler, + ...productDetailMockHandler, + ...productOptionsMockHandler +); From c1bc34314d503e85edac4cf9e879f4c153122a45 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:28:24 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20API=20=EB=AA=A8=ED=82=B9=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getProductOptionsPath 함수 사용하여 제품 옵션 API 엔드포인트 정의 - productOptionsMockHandler를 통해 제품 옵션 API 요청 모킹 - productId에 따라 응답 데이터 분기 처리 - 존재하지 않는 productId에 대해 404 에러 응답 추가 --- src/api/hooks/productOptions.mock.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index e69de29b..6413a856 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -0,0 +1,32 @@ +import { rest } from 'msw'; + +import { getProductOptionsPath } from './useGetProductOptions'; + +// 제품 옵션 모킹 핸들러 +export const productOptionsMockHandler = [ + rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + const { productId } = req.params; + + // 예시: productId에 따라 다른 데이터를 반환할 수 있습니다. + if (productId === '1') { + return res( + ctx.status(200), + ctx.json([ + { + id: 1, + name: 'Option A', + quantity: 10, + productId: 1, + }, + { + id: 2, + name: 'Option B', + quantity: 20, + productId: 1, + }, + ]), + ); + } + return res(ctx.status(404), ctx.json({ error: 'Product not found' })); + }), +]; From 799ed8ab9205fee2416765ee76dd4a8a02e4abbe Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:28:44 +0900 Subject: [PATCH 06/43] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20API=20=EB=AA=A8=ED=82=B9=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getProductDetailPath 함수 사용하여 제품 상세 정보 API 엔드포인트 정의 - productDetailMockHandler를 통해 제품 상세 정보 API 요청 모킹 - productId에 따라 응답 데이터 분기 처리 - 존재하지 않는 productId에 대해 404 에러 응답 추가 --- src/api/hooks/productDetail.mock.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index e69de29b..9d6e1dd7 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -0,0 +1,26 @@ +import { rest } from 'msw'; + +import { getProductDetailPath } from './useGetProductDetail'; + +// 제품 상세 정보 모킹 핸들러 +export const productDetailMockHandler = [ + rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + const { productId } = req.params; + + // 예시: productId에 따라 다른 데이터를 반환할 수 있습니다. + if (productId === '1') { + return res( + ctx.status(200), + ctx.json({ + id: 1, + name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', + price: 145000, + imageUrl: + 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', + categoryId: 2920, + }), + ); + } + return res(ctx.status(404), ctx.json({ error: 'Product not found' })); + }), +]; From cb5493999fa718a7571dd3cffb76f390fca6a35e Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:29:22 +0900 Subject: [PATCH 07/43] =?UTF-8?q?chore:=20ESLint=20=EB=A3=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 전체에 ESLint 룰 적용 - src 디렉토리의 .ts, .tsx, .js, .jsx 파일에 대해 lint 스크립트 추가 - lint:fix 스크립트 추가하여 자동으로 코드 스타일 수정 가능 - Prettier 설정 추가하여 코드 포맷팅 일관성 유지 - 관련 종속성 및 플러그인 설치 --- README.md | 11 ++++------- package.json | 5 ++++- src/mocks/browser.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 43dfb25c..a7a350a9 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,13 @@ ### 테스트 기반 환경 구축 -- Jest와 React Testing Library를 사용하여 테스트 환경을 설정 - - [ ] 필요한 패키지 설치 - - [ ] Jest 설정 파일 추가 - - [ ] 테스트 설정 파일 추가 +- [ ] Jest 테스트 환경 설정 +- [ ] React Testing Library 테스트 환경 설정 ### MSW를 사용하여 Mock API 설정 -- [ ] Mock API 서버 설정 -- [ ] 상세 API 및 옵션 API 엔드포인트 추가 -- [ ] `msw` 설정 파일 작성 +- [ ] 상세 API 엔드포인트 추가 +- [ ] 옵션 API 엔드포인트 추가 ### 단위 테스트 작성 diff --git a/package.json b/package.json index 1df1cf87..daf071a6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "craco build", "test": "craco test", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "lint": "eslint \"./src/**/*.{ts,tsx,js,jsx}\"", + "lint:fix": "eslint --fix \"./src/**/*.{ts,tsx,js,jsx}\"", + "prettier": "prettier --write \"./src/**/*.{ts,tsx,js,jsx}\"" }, "browserslist": { "production": [ diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index 8ce5e9e0..eaeb18cb 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -1,9 +1,9 @@ import { setupWorker } from 'msw'; import { categoriesMockHandler } from '@/api/hooks/categories.mock'; -import { productsMockHandler } from '@/api/hooks/products.mock'; import { productDetailMockHandler } from '@/api/hooks/productDetail.mock'; -import { productOptionsMockHandler } from './productOptions.mock'; +import { productOptionsMockHandler } from '@/api/hooks/productOptions.mock'; +import { productsMockHandler } from '@/api/hooks/products.mock'; export const worker = setupWorker( ...categoriesMockHandler, From a75ce6613ec52851177c09d6a661a3953f596831 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:35:47 +0900 Subject: [PATCH 08/43] =?UTF-8?q?refactor:=20useGetCategorys=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20categories.mock.ts=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=A8=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGetCategories 훅에서 실제 API 호출 대신 categories.mock.ts의 모킹 데이터 사용 - CATEGORIES_RESPONSE_DATA와 getCategoriesPath를 categories.mock.ts로 이동 및 정의 - getCategories 함수가 모킹 데이터를 반환하도록 수정 - MSW 핸들러 정의 및 설정 추가 --- src/api/hooks/categories.mock.ts | 21 ++++++++++++--------- src/api/hooks/useGetCategorys.ts | 11 +++++++---- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/api/hooks/categories.mock.ts b/src/api/hooks/categories.mock.ts index f8d65843..5fdf90be 100644 --- a/src/api/hooks/categories.mock.ts +++ b/src/api/hooks/categories.mock.ts @@ -1,14 +1,7 @@ import { rest } from 'msw'; -import { getCategoriesPath } from './useGetCategorys'; - -export const categoriesMockHandler = [ - rest.get(getCategoriesPath(), (_, res, ctx) => { - return res(ctx.json(CATEGORIES_RESPONSE_DATA)); - }), -]; - -const CATEGORIES_RESPONSE_DATA = [ +// 기존에 정의된 카테고리 데이터 +export const CATEGORIES_RESPONSE_DATA = [ { id: 2920, name: '생일', @@ -26,3 +19,13 @@ const CATEGORIES_RESPONSE_DATA = [ 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Fst.kakaocdn.net%2Fproduct%2Fgift%2Fproduct%2F20240131153049_5a22b137a8d346e9beb020a7a7f4254a.jpg', }, ]; + +// 카테고리 API 경로 +export const getCategoriesPath = () => '/api/categories'; + +// MSW 핸들러 정의 +export const categoriesMockHandler = [ + rest.get(getCategoriesPath(), (_, res, ctx) => { + return res(ctx.json(CATEGORIES_RESPONSE_DATA)); + }), +]; diff --git a/src/api/hooks/useGetCategorys.ts b/src/api/hooks/useGetCategorys.ts index d93e4fc9..1ca45d87 100644 --- a/src/api/hooks/useGetCategorys.ts +++ b/src/api/hooks/useGetCategorys.ts @@ -2,18 +2,21 @@ import { useQuery } from '@tanstack/react-query'; import type { CategoryData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { CATEGORIES_RESPONSE_DATA, getCategoriesPath } from './categories.mock'; +// 모킹 데이터 타입 정의 export type CategoryResponseData = CategoryData[]; -export const getCategoriesPath = () => `${BASE_URL}/api/categories`; +// React Query에서 사용할 쿼리 키 const categoriesQueryKey = [getCategoriesPath()]; +// 모킹 데이터를 반환하는 함수 export const getCategories = async () => { - const response = await fetchInstance.get(getCategoriesPath()); - return response.data; + // 실제 API 호출 대신 모킹 데이터를 반환합니다. + return CATEGORIES_RESPONSE_DATA; }; +// useGetCategories 훅 export const useGetCategories = () => useQuery({ queryKey: categoriesQueryKey, From 8d4c1e4f7aa40ed083f208aaa4fdfd2f100433de Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:41:47 +0900 Subject: [PATCH 09/43] =?UTF-8?q?refactor:=20useGetProducts=EC=97=90?= =?UTF-8?q?=EC=84=9C=20pageParam=20=EC=82=AC=EC=9A=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용되지 않는 pageParam 변수 제거 - useGetProducts 훅에서 queryFn 수정 - ESLint 오류 해결 --- src/api/hooks/products.mock.ts | 77 +++++++++++++++------------------ src/api/hooks/useGetProducts.ts | 32 +++----------- 2 files changed, 40 insertions(+), 69 deletions(-) diff --git a/src/api/hooks/products.mock.ts b/src/api/hooks/products.mock.ts index 6cef1123..f9c2b910 100644 --- a/src/api/hooks/products.mock.ts +++ b/src/api/hooks/products.mock.ts @@ -1,50 +1,14 @@ import { rest } from 'msw'; -import { getProductDetailPath } from './useGetProductDetail'; -import { getProductOptionsPath } from './useGetProductOptions'; -import { getProductsPath } from './useGetProducts'; +const BASE_URL = 'http://localhost:3000'; -export const productsMockHandler = [ - rest.get( - getProductsPath({ - categoryId: '2920', - }), - (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA)); - }, - ), - rest.get( - getProductsPath({ - categoryId: '2930', - }), - (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA)); - }, - ), - rest.get(getProductDetailPath(':productId'), (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA.content[0])); - }), - rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { - return res( - ctx.json([ - { - id: 1, - name: 'Option A', - quantity: 10, - productId: 1, - }, - { - id: 2, - name: 'Option B', - quantity: 20, - productId: 1, - }, - ]), - ); - }), -]; +type RequestParams = { + categoryId: string; + pageToken?: string; + maxResults?: number; +}; -const PRODUCTS_MOCK_DATA = { +export const PRODUCTS_MOCK_DATA = { content: [ { id: 3245119, @@ -52,6 +16,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', price: 145000, + categoryId: 2920, }, { id: 2263833, @@ -59,6 +24,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20200513102805_4867c1e4a7ae43b5825e9ae14e2830e3.png', price: 100000, + categoryId: 2930, }, { id: 6502823, @@ -66,6 +32,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240215112140_11f857e972bc4de6ac1d2f1af47ce182.jpg', price: 108000, + categoryId: 2930, }, { id: 1181831, @@ -73,6 +40,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240214150740_ad25267defa64912a7c030a7b57dc090.jpg', price: 122000, + categoryId: 2920, }, { id: 1379982, @@ -80,6 +48,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240118135914_a6e1a7442ea04aa49add5e02ed62b4c3.jpg', price: 133000, + categoryId: 2920, }, ], number: 0, @@ -87,3 +56,25 @@ const PRODUCTS_MOCK_DATA = { size: 10, last: true, }; + +// 카테고리별 제품 목록 API 경로 +export const getProductsPath = ({ categoryId, pageToken, maxResults }: RequestParams) => { + const params = new URLSearchParams(); + + params.append('categoryId', categoryId); + params.append('sort', 'name,asc'); + if (pageToken) params.append('page', pageToken); + if (maxResults) params.append('size', maxResults.toString()); + + return `${BASE_URL}/api/products?${params.toString()}`; +}; + +// MSW 핸들러 정의 +export const productsMockHandler = [ + rest.get(getProductsPath({ categoryId: '2920' }), (_, res, ctx) => { + return res(ctx.json(PRODUCTS_MOCK_DATA)); + }), + rest.get(getProductsPath({ categoryId: '2930' }), (_, res, ctx) => { + return res(ctx.json(PRODUCTS_MOCK_DATA)); + }), +]; diff --git a/src/api/hooks/useGetProducts.ts b/src/api/hooks/useGetProducts.ts index 432f90d9..d45d627b 100644 --- a/src/api/hooks/useGetProducts.ts +++ b/src/api/hooks/useGetProducts.ts @@ -6,8 +6,7 @@ import { import type { ProductData } from '@/types'; -import { BASE_URL } from '../instance'; -import { fetchInstance } from './../instance/index'; +import { PRODUCTS_MOCK_DATA } from './products.mock'; type RequestParams = { categoryId: string; @@ -24,28 +23,9 @@ type ProductsResponseData = { }; }; -type ProductsResponseRawData = { - content: ProductData[]; - number: number; - totalElements: number; - size: number; - last: boolean; -}; - -export const getProductsPath = ({ categoryId, pageToken, maxResults }: RequestParams) => { - const params = new URLSearchParams(); - - params.append('categoryId', categoryId); - params.append('sort', 'name,asc'); - if (pageToken) params.append('page', pageToken); - if (maxResults) params.append('size', maxResults.toString()); - - return `${BASE_URL}/api/products?${params.toString()}`; -}; - -export const getProducts = async (params: RequestParams): Promise => { - const response = await fetchInstance.get(getProductsPath(params)); - const data = response.data; +// 모킹 데이터를 반환하는 함수 +export const getProducts = async (): Promise => { + const data = PRODUCTS_MOCK_DATA; return { products: data.content, @@ -65,8 +45,8 @@ export const useGetProducts = ({ }: Params): UseInfiniteQueryResult> => { return useInfiniteQuery({ queryKey: ['products', categoryId, maxResults, initPageToken], - queryFn: async ({ pageParam = initPageToken }) => { - return getProducts({ categoryId, pageToken: pageParam, maxResults }); + queryFn: async () => { + return getProducts(); }, initialPageParam: initPageToken, getNextPageParam: (lastPage) => lastPage.nextPageToken, From 0c390c89c5fda6fab18a7dde5b4b77d4912a5429 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 20:47:12 +0900 Subject: [PATCH 10/43] =?UTF-8?q?fix:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20API=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EC=99=80=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - productDetail.mock.ts에서 핸들러 경로를 '/api/products/:productId'로 정확히 설정 - useGetProductDetail.ts에서 요청된 productId를 사용하여 모킹 데이터 반환 --- src/api/hooks/productDetail.mock.ts | 6 +++--- src/api/hooks/useGetProductDetail.ts | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index 9d6e1dd7..2fb6d9ba 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -1,10 +1,10 @@ import { rest } from 'msw'; -import { getProductDetailPath } from './useGetProductDetail'; +export const getProductDetailPath = (productId: string) => `/api/products/${productId}`; -// 제품 상세 정보 모킹 핸들러 +// 제품 상세 정보 모킹 데이터 export const productDetailMockHandler = [ - rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + rest.get('/api/products/:productId', (req, res, ctx) => { const { productId } = req.params; // 예시: productId에 따라 다른 데이터를 반환할 수 있습니다. diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 539de019..2c6d42e6 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -2,7 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { ProductData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { getProductDetailPath } from './productDetail.mock'; export type ProductDetailRequestParams = { productId: string; @@ -12,14 +12,20 @@ type Props = ProductDetailRequestParams; export type GoodsDetailResponseData = ProductData; -export const getProductDetailPath = (productId: string) => `${BASE_URL}/api/products/${productId}`; - +// 실제 API 호출 대신 모킹 데이터를 반환하는 함수 export const getProductDetail = async (params: ProductDetailRequestParams) => { - const response = await fetchInstance.get( - getProductDetailPath(params.productId), - ); - - return response.data; + const { productId } = params; + if (productId === '1') { + return { + id: 1, + name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', + price: 145000, + imageUrl: + 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', + categoryId: 2920, + }; + } + throw new Error('Product not found'); }; export const useGetProductDetail = ({ productId }: Props) => { From 394ca8e2dbc820335a2c6ae9d4dea5b3ec068dcc Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:12:35 +0900 Subject: [PATCH 11/43] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=B0=8F=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EB=AA=A8=ED=82=B9=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/productOptions.mock.ts | 4 +++- src/api/hooks/useGetProductDetail.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index 6413a856..76932943 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -27,6 +27,8 @@ export const productOptionsMockHandler = [ ]), ); } - return res(ctx.status(404), ctx.json({ error: 'Product not found' })); + + // productId가 1이 아닐 경우 빈 배열 반환 + return res(ctx.status(200), ctx.json([])); }), ]; diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 2c6d42e6..1a9f2f53 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -10,10 +10,10 @@ export type ProductDetailRequestParams = { type Props = ProductDetailRequestParams; -export type GoodsDetailResponseData = ProductData; +export type GoodsDetailResponseData = ProductData | null; // 실제 API 호출 대신 모킹 데이터를 반환하는 함수 -export const getProductDetail = async (params: ProductDetailRequestParams) => { +export const getProductDetail = async (params: ProductDetailRequestParams): Promise => { const { productId } = params; if (productId === '1') { return { @@ -22,10 +22,12 @@ export const getProductDetail = async (params: ProductDetailRequestParams) => { price: 145000, imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', - categoryId: 2920, + categoryId: 1, }; } - throw new Error('Product not found'); + + // null 반환 + return null; }; export const useGetProductDetail = ({ productId }: Props) => { From 8468844aba0685476fc4ab3ac0fbb530d246d59f Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:15:03 +0900 Subject: [PATCH 12/43] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=97=A4=EB=8D=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20null=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoodsDetailHeader 컴포넌트에서 detail 데이터가 null인 경우 로딩 메시지를 표시하도록 수정하여 예외 처리 개선 --- src/components/features/Goods/Detail/Header.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/features/Goods/Detail/Header.tsx b/src/components/features/Goods/Detail/Header.tsx index 1888f597..910f7051 100644 --- a/src/components/features/Goods/Detail/Header.tsx +++ b/src/components/features/Goods/Detail/Header.tsx @@ -10,6 +10,10 @@ type Props = ProductDetailRequestParams; export const GoodsDetailHeader = ({ productId }: Props) => { const { data: detail } = useGetProductDetail({ productId }); + if (!detail) { + return
Loading...
; // 또는 에러 메시지 + } + return ( From 68c6c14d37695831ba10ea80acbd7a0e8fe36272 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:20:24 +0900 Subject: [PATCH 13/43] fix: handle undefined price and add error handling in OptionSection component - Added default value for price to avoid TypeScript errors when detail.price is undefined. - Refactored totalPrice calculation to ensure safety and correct default value. - Included error handling for product detail and options fetching. - Displayed user-friendly messages for loading and error states. --- .../features/Goods/Detail/OptionSection.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/features/Goods/Detail/OptionSection.tsx b/src/components/features/Goods/Detail/OptionSection.tsx index 7951a26c..6319f9cf 100644 --- a/src/components/features/Goods/Detail/OptionSection.tsx +++ b/src/components/features/Goods/Detail/OptionSection.tsx @@ -17,16 +17,19 @@ import { CountOptionItem } from './OptionItem/CountOptionItem'; type Props = ProductDetailRequestParams; export const OptionSection = ({ productId }: Props) => { - const { data: detail } = useGetProductDetail({ productId }); - const { data: options } = useGetProductOptions({ productId }); + const { data: detail, error: detailError } = useGetProductDetail({ productId }); + const { data: options, error: optionsError } = useGetProductOptions({ productId }); const [countAsString, setCountAsString] = useState('1'); + const totalPrice = useMemo(() => { - return detail.price * Number(countAsString); - }, [detail, countAsString]); + const price = detail?.price ?? 0; // Provide default value of 0 if price is undefined + return price * Number(countAsString); + }, [detail?.price, countAsString]); const navigate = useNavigate(); const authInfo = useAuth(); + const handleClick = () => { if (!authInfo) { const isConfirm = window.confirm( @@ -45,9 +48,21 @@ export const OptionSection = ({ productId }: Props) => { navigate(RouterPath.order); }; + if (!detail) { + return
Loading...
; // Handle loading state + } + + if (optionsError || detailError) { + return
Error loading product details or options.
; // Handle errors + } + return ( - + 0 ? options[0].name : ''} + value={countAsString} + onChange={setCountAsString} + /> 총 결제 금액 {totalPrice}원 From f8c5bdf0aca9857f14f1da83ee5810ed5665e23d Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:22:35 +0900 Subject: [PATCH 14/43] fix: handle null detail and remove unused error variable in GoodsInfo component - Added null checks for detail to prevent runtime errors when detail is not available. - Removed unused 'error' variable from destructuring as it is not utilized in the component. - Provided fallback UI for loading state to enhance user experience. --- .../Order/OrderForm/GoodsInfo/index.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx index caced66c..092d5136 100644 --- a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx +++ b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx @@ -10,10 +10,26 @@ import { LabelText } from '../Common/LabelText'; type Props = { orderHistory: OrderHistory; }; + export const GoodsInfo = ({ orderHistory }: Props) => { const { id, count } = orderHistory; const { data: detail } = useGetProductDetail({ productId: id.toString() }); + // Check if detail is null or undefined + if (!detail) { + return ( + + 선물내역 + + + +
Loading...
{/* Or any fallback content */} +
+
+
+ ); + } + return ( 선물내역 @@ -68,5 +84,4 @@ const GoodsInfoTextTitle = styled.p` margin-top: 3px; color: #222; overflow: hidden; - font-weight: 400; `; From ea21d7a67748121aad7a0c33dfae0173405e0bec Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:27:52 +0900 Subject: [PATCH 15/43] fix: handle 'detail' possibly being null Add null check for 'detail' to prevent TypeScript error when calculating total price. Use optional chaining and nullish coalescing operator to ensure 'detail' is safely accessed. --- .../features/Order/OrderForm/GoodsInfo/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx index 092d5136..e4e02f36 100644 --- a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx +++ b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx @@ -15,7 +15,7 @@ export const GoodsInfo = ({ orderHistory }: Props) => { const { id, count } = orderHistory; const { data: detail } = useGetProductDetail({ productId: id.toString() }); - // Check if detail is null or undefined + // Use a conditional check to handle the case where detail might be null or undefined if (!detail) { return ( @@ -30,6 +30,9 @@ export const GoodsInfo = ({ orderHistory }: Props) => { ); } + // Now TypeScript understands that detail is not null here + const totalPrice = (detail.price ?? 0) * count; // Use optional chaining to safely access properties + return ( 선물내역 @@ -43,6 +46,8 @@ export const GoodsInfo = ({ orderHistory }: Props) => { {detail.name} X {count}개 + {/* You can also show the totalPrice if needed */} + 총 결제 금액: {totalPrice}원 @@ -85,3 +90,9 @@ const GoodsInfoTextTitle = styled.p` color: #222; overflow: hidden; `; + +const TotalPrice = styled.p` + font-size: 16px; + font-weight: 700; + color: #111; +`; From b73df393ed1498d115bcdbfae46e9d937bfd4151 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:34:50 +0900 Subject: [PATCH 16/43] feat: Implement product options data fetching and mocking - Added `getProductOptionsPath` function to construct API URL for product options. - Created `getProductOptions` function to fetch product options from the server. - Developed `useGetProductOptions` custom hook using `useSuspenseQuery` for data fetching. - Implemented MSW mock handler for product options: - Added mock data for productId '1'. - Returned empty array for other productIds. --- README.md | 8 ++++---- src/api/hooks/productOptions.mock.ts | 6 ++---- src/api/hooks/useGetProductOptions.ts | 5 ++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a7a350a9..ecf131bf 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ ### 테스트 기반 환경 구축 -- [ ] Jest 테스트 환경 설정 -- [ ] React Testing Library 테스트 환경 설정 +- [x] Jest 테스트 환경 설정 +- [x] React Testing Library 테스트 환경 설정 ### MSW를 사용하여 Mock API 설정 -- [ ] 상세 API 엔드포인트 추가 -- [ ] 옵션 API 엔드포인트 추가 +- [x] 상세 API 엔드포인트 추가 +- [x] 옵션 API 엔드포인트 추가 ### 단위 테스트 작성 diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index 76932943..192472c3 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -7,7 +7,6 @@ export const productOptionsMockHandler = [ rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { const { productId } = req.params; - // 예시: productId에 따라 다른 데이터를 반환할 수 있습니다. if (productId === '1') { return res( ctx.status(200), @@ -24,11 +23,10 @@ export const productOptionsMockHandler = [ quantity: 20, productId: 1, }, - ]), + ]) ); } - // productId가 1이 아닐 경우 빈 배열 반환 - return res(ctx.status(200), ctx.json([])); + return res(ctx.status(200), ctx.json([])); // 다른 productId에 대한 빈 배열 }), ]; diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index a3bdc538..7b87723a 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -9,16 +9,19 @@ type Props = ProductDetailRequestParams; export type ProductOptionsResponseData = ProductOptionsData[]; +// 제품 옵션 API 경로 생성 export const getProductOptionsPath = (productId: string) => `${BASE_URL}/api/products/${productId}/options`; +// 제품 옵션 가져오기 export const getProductOptions = async (params: ProductDetailRequestParams) => { const response = await fetchInstance.get( - getProductOptionsPath(params.productId), + getProductOptionsPath(params.productId) ); return response.data; }; +// 제품 옵션 훅 export const useGetProductOptions = ({ productId }: Props) => { return useSuspenseQuery({ queryKey: [getProductOptionsPath(productId)], From d8795063bd8962bc17c442c2e1761002f89ffe2c Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 21:38:24 +0900 Subject: [PATCH 17/43] =?UTF-8?q?ix:=20=EC=83=81=ED=92=88=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/features/Order/OrderForm/OrderInfo/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/Order/OrderForm/OrderInfo/index.tsx b/src/components/features/Order/OrderForm/OrderInfo/index.tsx index 97124149..9d82b76d 100644 --- a/src/components/features/Order/OrderForm/OrderInfo/index.tsx +++ b/src/components/features/Order/OrderForm/OrderInfo/index.tsx @@ -17,7 +17,7 @@ export const OrderFormOrderInfo = ({ orderHistory }: Props) => { const { id, count } = orderHistory; const { data: detail } = useGetProductDetail({ productId: id.toString() }); - const totalPrice = detail.price * count; + const totalPrice = (detail?.price ?? 0) * count; return ( From 2b1263bf1b92ffa24de88683d5fccda1f8f5e1a9 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 23:13:02 +0900 Subject: [PATCH 18/43] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20MSW=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGetProductDetail 훅을 사용하여 제품 상세 정보를 가져오는 기능 구현 - getProductDetail 함수를 통해 실제 API 호출 로직 구현 - productDetailMockHandler를 사용하여 MSW 환경에서 API 응답 모킹 - 타입 안정성을 위해 ProductDetailResponseData 타입 및 zod 스키마 정의 - getProductDetailPath 함수를 별도 파일로 분리 --- package-lock.json | 11 ++++++- package.json | 3 +- src/api/hooks/productDetail.mock.ts | 44 +++++++++++++++++---------- src/api/hooks/productDetailPath.ts | 2 ++ src/api/hooks/productOptions.mock.ts | 41 +++++++++++++------------ src/api/hooks/productOptionsPath.ts | 3 ++ src/api/hooks/useGetProductDetail.ts | 32 +++++-------------- src/api/hooks/useGetProductOptions.ts | 37 +++++++++++----------- 8 files changed, 94 insertions(+), 79 deletions(-) create mode 100644 src/api/hooks/productDetailPath.ts create mode 100644 src/api/hooks/productOptionsPath.ts diff --git a/package-lock.json b/package-lock.json index ed0d135c..8fce37b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "react-error-boundary": "^4.0.12", "react-hook-form": "^7.50.1", "react-intersection-observer": "^9.8.1", - "react-router-dom": "^6.22.1" + "react-router-dom": "^6.22.1", + "zod": "^3.23.8" }, "devDependencies": { "@craco/craco": "^7.1.0", @@ -35485,6 +35486,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index daf071a6..f2eab548 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "react-error-boundary": "^4.0.12", "react-hook-form": "^7.50.1", "react-intersection-observer": "^9.8.1", - "react-router-dom": "^6.22.1" + "react-router-dom": "^6.22.1", + "zod": "^3.23.8" }, "devDependencies": { "@craco/craco": "^7.1.0", diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index 2fb6d9ba..8dcd621e 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -1,26 +1,38 @@ import { rest } from 'msw'; +import { z } from 'zod'; -export const getProductDetailPath = (productId: string) => `/api/products/${productId}`; +import { getProductDetailPath } from './productDetailPath'; // 별도 파일로 분리 -// 제품 상세 정보 모킹 데이터 +// 제품 상세 정보 데이터 스키마 (zod) +const productDetailResponseDataSchema = z.object({ + id: z.number(), + name: z.string(), + price: z.number(), + imageUrl: z.string(), + categoryId: z.number(), +}); + +export type ProductDetailResponseData = z.infer; + +// 샘플 제품 상세 정보 데이터 +const sampleProductDetail: ProductDetailResponseData = { + id: 1, + name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', + price: 145000, + imageUrl: + 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', + categoryId: 2920, +}; + +// MSW 핸들러 (API 모킹) export const productDetailMockHandler = [ - rest.get('/api/products/:productId', (req, res, ctx) => { + rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { const { productId } = req.params; - // 예시: productId에 따라 다른 데이터를 반환할 수 있습니다. - if (productId === '1') { - return res( - ctx.status(200), - ctx.json({ - id: 1, - name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', - price: 145000, - imageUrl: - 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', - categoryId: 2920, - }), - ); + if (productId === sampleProductDetail.id.toString()) { + return res(ctx.json(sampleProductDetail)); } + return res(ctx.status(404), ctx.json({ error: 'Product not found' })); }), ]; diff --git a/src/api/hooks/productDetailPath.ts b/src/api/hooks/productDetailPath.ts new file mode 100644 index 00000000..f33b55fd --- /dev/null +++ b/src/api/hooks/productDetailPath.ts @@ -0,0 +1,2 @@ +const BASE_URL = 'http://localhost:3000'; +export const getProductDetailPath = (productId: string) => `${BASE_URL}/products/${productId}`; diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index 192472c3..eb15904b 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -1,6 +1,25 @@ import { rest } from 'msw'; +import { z } from 'zod'; -import { getProductOptionsPath } from './useGetProductOptions'; +import { getProductOptionsPath } from './productOptionsPath'; + +// 제품 옵션 API 응답 스키마 (zod) +const productOptionsResponseDataSchema = z.array( + z.object({ + id: z.number(), + name: z.string(), + quantity: z.number(), + productId: z.number(), + }) +); + +export type ProductOptionsResponseData = z.infer; + +// 샘플 제품 옵션 데이터 (productId: 1) +const sampleProductOptions: ProductOptionsResponseData = [ + { id: 1, name: 'Option A', quantity: 10, productId: 1 }, + { id: 2, name: 'Option B', quantity: 20, productId: 1 }, +]; // 제품 옵션 모킹 핸들러 export const productOptionsMockHandler = [ @@ -8,25 +27,9 @@ export const productOptionsMockHandler = [ const { productId } = req.params; if (productId === '1') { - return res( - ctx.status(200), - ctx.json([ - { - id: 1, - name: 'Option A', - quantity: 10, - productId: 1, - }, - { - id: 2, - name: 'Option B', - quantity: 20, - productId: 1, - }, - ]) - ); + return res(ctx.status(200), ctx.json(sampleProductOptions)); } - return res(ctx.status(200), ctx.json([])); // 다른 productId에 대한 빈 배열 + return res(ctx.status(404), ctx.json({ message: 'Product not found' })); }), ]; diff --git a/src/api/hooks/productOptionsPath.ts b/src/api/hooks/productOptionsPath.ts new file mode 100644 index 00000000..7e0541eb --- /dev/null +++ b/src/api/hooks/productOptionsPath.ts @@ -0,0 +1,3 @@ +const BASE_URL = 'http://localhost:3000'; +export const getProductOptionsPath = (productId: string) => + `${BASE_URL}/api/products/${productId}/options`; diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 1a9f2f53..a27a7009 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -1,36 +1,20 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import type { ProductData } from '@/types'; - -import { getProductDetailPath } from './productDetail.mock'; +import { fetchInstance } from '../instance'; +import type { ProductDetailResponseData } from './productDetail.mock'; +import { getProductDetailPath } from './productDetailPath'; export type ProductDetailRequestParams = { productId: string; }; -type Props = ProductDetailRequestParams; - -export type GoodsDetailResponseData = ProductData | null; - -// 실제 API 호출 대신 모킹 데이터를 반환하는 함수 -export const getProductDetail = async (params: ProductDetailRequestParams): Promise => { - const { productId } = params; - if (productId === '1') { - return { - id: 1, - name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', - price: 145000, - imageUrl: - 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', - categoryId: 1, - }; - } - - // null 반환 - return null; +// 실제 API 호출 함수 +export const getProductDetail = async ({ productId }: ProductDetailRequestParams): Promise => { + const response = await fetchInstance.get(getProductDetailPath(productId)); + return response.data; }; -export const useGetProductDetail = ({ productId }: Props) => { +export const useGetProductDetail = ({ productId }: ProductDetailRequestParams) => { return useSuspenseQuery({ queryKey: [getProductDetailPath(productId)], queryFn: () => getProductDetail({ productId }), diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index 7b87723a..d2a430f8 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -1,30 +1,31 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; -import type { ProductOptionsData } from '@/types'; - -import { BASE_URL, fetchInstance } from '../instance'; +import { fetchInstance } from '../instance'; +import type { ProductOptionsResponseData } from './productOptions.mock'; +import { getProductOptionsPath } from './productOptionsPath'; import type { ProductDetailRequestParams } from './useGetProductDetail'; type Props = ProductDetailRequestParams; -export type ProductOptionsResponseData = ProductOptionsData[]; - -// 제품 옵션 API 경로 생성 -export const getProductOptionsPath = (productId: string) => - `${BASE_URL}/api/products/${productId}/options`; - -// 제품 옵션 가져오기 -export const getProductOptions = async (params: ProductDetailRequestParams) => { +// 데이터 가져오는 함수 +export const getProductOptions = async ({ productId }: Props): Promise => { const response = await fetchInstance.get( - getProductOptionsPath(params.productId) + getProductOptionsPath(productId) ); return response.data; }; -// 제품 옵션 훅 -export const useGetProductOptions = ({ productId }: Props) => { - return useSuspenseQuery({ - queryKey: [getProductOptionsPath(productId)], - queryFn: () => getProductOptions({ productId }), +// React Query 훅 +export const useGetProductOptions = ( + params: Props, + options?: UseQueryOptions +): UseQueryResult => { + return useQuery({ + ...options, + queryKey: [getProductOptionsPath(params.productId)], + queryFn: () => getProductOptions(params), + enabled: !!params.productId, // productId가 있을 때만 쿼리 활성화 + retry: false, }); }; From 40e33f7fb465d1261a8f348d51cc3a26fa5f0439 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 23:27:12 +0900 Subject: [PATCH 19/43] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20API=20=EB=AA=A8=ED=82=B9=20?= =?UTF-8?q?=EB=B0=8F=20useSuspenseQuery=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제품 상세 정보 조회 기능 구현 MSW를 사용하여 제품 상세 정보 API 모킹 --- src/api/hooks/productDetail.mock.ts | 2 +- src/api/hooks/productDetailPath.ts | 7 +++++-- src/api/hooks/productOptions.mock.ts | 2 +- src/api/hooks/productOptionsPath.ts | 8 +++++--- src/api/hooks/useGetProductOptions.ts | 13 +++++++------ 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index 8dcd621e..d1cc4199 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -1,7 +1,7 @@ import { rest } from 'msw'; import { z } from 'zod'; -import { getProductDetailPath } from './productDetailPath'; // 별도 파일로 분리 +import { getProductDetailPath } from './productDetailPath'; // getProductDetailPath 함수 import // 제품 상세 정보 데이터 스키마 (zod) const productDetailResponseDataSchema = z.object({ diff --git a/src/api/hooks/productDetailPath.ts b/src/api/hooks/productDetailPath.ts index f33b55fd..9a790d8b 100644 --- a/src/api/hooks/productDetailPath.ts +++ b/src/api/hooks/productDetailPath.ts @@ -1,2 +1,5 @@ -const BASE_URL = 'http://localhost:3000'; -export const getProductDetailPath = (productId: string) => `${BASE_URL}/products/${productId}`; +// 카테고리별 제품 상세 정보 API 경로 +export const getProductDetailPath = (productId: string | number) => { +   return `http://localhost:3000/api/products/${productId}`; + }; + \ No newline at end of file diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index eb15904b..71ab8c2a 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -1,7 +1,7 @@ import { rest } from 'msw'; import { z } from 'zod'; -import { getProductOptionsPath } from './productOptionsPath'; +import { getProductOptionsPath } from './productOptionsPath'; // getProductOptionsPath 함수 import // 제품 옵션 API 응답 스키마 (zod) const productOptionsResponseDataSchema = z.array( diff --git a/src/api/hooks/productOptionsPath.ts b/src/api/hooks/productOptionsPath.ts index 7e0541eb..9dfa6998 100644 --- a/src/api/hooks/productOptionsPath.ts +++ b/src/api/hooks/productOptionsPath.ts @@ -1,3 +1,5 @@ -const BASE_URL = 'http://localhost:3000'; -export const getProductOptionsPath = (productId: string) => - `${BASE_URL}/api/products/${productId}/options`; +// 카테고리별 제품 옵션 API 경로 +export const getProductOptionsPath = (productId: string | number) => { +   return `http://localhost:3000/api/products/${productId}/options`; + }; + \ No newline at end of file diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index d2a430f8..0d62c0af 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -1,12 +1,13 @@ import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -import { fetchInstance } from '../instance'; -import type { ProductOptionsResponseData } from './productOptions.mock'; -import { getProductOptionsPath } from './productOptionsPath'; -import type { ProductDetailRequestParams } from './useGetProductDetail'; +import { fetchInstance } from '../instance'; // 실제 API 통신을 위한 fetchInstance +import type { ProductOptionsResponseData } from './productOptions.mock'; // 타입 공유 +import { getProductOptionsPath } from './productOptionsPath'; // path 생성 함수 공유 -type Props = ProductDetailRequestParams; +type Props = { + productId: string; +}; // 데이터 가져오는 함수 export const getProductOptions = async ({ productId }: Props): Promise => { @@ -23,7 +24,7 @@ export const useGetProductOptions = ( ): UseQueryResult => { return useQuery({ ...options, - queryKey: [getProductOptionsPath(params.productId)], + queryKey: [getProductOptionsPath(params.productId)], // 쿼리 키에 동적 productId 포함 queryFn: () => getProductOptions(params), enabled: !!params.productId, // productId가 있을 때만 쿼리 활성화 retry: false, From d4845d68246b18d8b81d561328e5c4246469f511 Mon Sep 17 00:00:00 2001 From: yuni Date: Thu, 25 Jul 2024 23:39:44 +0900 Subject: [PATCH 20/43] =?UTF-8?q?docs:=20step1=20msw=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d4831e77..33c7a159 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# React Product Login +# React Product msw ## 개요 From 2ac29c5a81a1a5cef242cc997e4547c3c7ec4b84 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 00:27:18 +0900 Subject: [PATCH 21/43] =?UTF-8?q?test:=20useGetProducts=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGetProducts 훅의 getProducts 함수에 대한 단위 테스트를 추가했습니다. PRODUCTS_MOCK_DATA를 모킹하여 함수가 예상된 데이터를 반환하는지 검증했습니다. --- src/api/hooks/useGetProducts.test.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/api/hooks/useGetProducts.test.ts diff --git a/src/api/hooks/useGetProducts.test.ts b/src/api/hooks/useGetProducts.test.ts new file mode 100644 index 00000000..719a2e9b --- /dev/null +++ b/src/api/hooks/useGetProducts.test.ts @@ -0,0 +1,34 @@ + +import { getProducts } from './useGetProducts'; + +// Mocking the PRODUCTS_MOCK_DATA +jest.mock('./products.mock', () => ({ + PRODUCTS_MOCK_DATA: { + content: [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + ], + last: false, + number: 0, + size: 20, + totalElements: 40, + }, +})); + +describe('getProducts', () => { + it('should return products data', async () => { + const data = await getProducts(); + + expect(data).toEqual({ + products: [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + ], + nextPageToken: '1', + pageInfo: { + totalResults: 40, + resultsPerPage: 20, + }, + }); + }); +}); From a02b8480cf08ed5c3f558834476b054d0cb85190 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 01:42:15 +0900 Subject: [PATCH 22/43] =?UTF-8?q?test:=20getProductDetail=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getProductDetail 함수가 예상된 제품 상세 데이터를 반환하는지 확인하는 단위 테스트를 추가했습니다. axios를 모킹하여 실제 HTTP 요청을 보내지 않고 테스트할 수 있도록 했습니다. --- package-lock.json | 16 ++++++------ package.json | 2 +- src/api/hooks/useGetProductDetail.test.ts | 31 +++++++++++++++++++++++ src/api/hooks/useGetProducts.test.ts | 1 - 4 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 src/api/hooks/useGetProductDetail.test.ts diff --git a/package-lock.json b/package-lock.json index 8fce37b8..28d271ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", - "axios": "^1.6.7", + "axios": "^1.7.2", "framer-motion": "^11.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -13424,11 +13424,11 @@ } }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -19882,9 +19882,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index f2eab548..ed1d8013 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", - "axios": "^1.6.7", + "axios": "^1.7.2", "framer-motion": "^11.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/api/hooks/useGetProductDetail.test.ts b/src/api/hooks/useGetProductDetail.test.ts new file mode 100644 index 00000000..a41ac493 --- /dev/null +++ b/src/api/hooks/useGetProductDetail.test.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; + +import type { ProductDetailResponseData } from './productDetail.mock'; +import { getProductDetailPath } from './productDetailPath'; +import { getProductDetail } from './useGetProductDetail'; + +// Mocking axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// Sample product detail response +const mockProductDetailResponse: ProductDetailResponseData = { + id: 1, + name: 'string', + price: 999, + imageUrl: 'string', + categoryId: 999 +}; + +describe('getProductDetail', () => { + it('should return product detail data', async () => { + // Mocking axios.get to return the mock response + mockedAxios.get.mockResolvedValueOnce({ data: mockProductDetailResponse }); + + const productId = '1'; + const data = await getProductDetail({ productId }); + + expect(axios.get).toHaveBeenCalledWith(getProductDetailPath(productId)); + expect(data).toEqual(mockProductDetailResponse); + }); +}); diff --git a/src/api/hooks/useGetProducts.test.ts b/src/api/hooks/useGetProducts.test.ts index 719a2e9b..850a1f4a 100644 --- a/src/api/hooks/useGetProducts.test.ts +++ b/src/api/hooks/useGetProducts.test.ts @@ -1,4 +1,3 @@ - import { getProducts } from './useGetProducts'; // Mocking the PRODUCTS_MOCK_DATA From b5c2014750791697f3621028c3d60831a7fe38fe Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 01:50:28 +0900 Subject: [PATCH 23/43] =?UTF-8?q?feat:=20=ED=98=84=EA=B8=88=EC=98=81?= =?UTF-8?q?=EC=88=98=EC=A6=9D=20=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8(CashReceiptFields)=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CashReceiptFields 컴포넌트의 렌더링 및 사용자 상호작용 테스트 추가 @testing-library/react 및 userEvent를 사용하여 테스트 구현 react-hook-form 연동 테스트는 별도로 분리하여 작성 예정 --- .../Fields/CashReceiptFields.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx diff --git a/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx b/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx new file mode 100644 index 00000000..66c8867b --- /dev/null +++ b/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CashReceiptFields } from './CashReceiptFields'; + +describe('CashReceiptFields', () => { + test('현금영수증 관련 입력 컴포넌트 렌더링 확인', () => { + render(); + + // 컴포넌트 존재 여부 확인 + const checkbox = screen.getByRole('checkbox', { name: '현금영수증 신청' }); + const select = screen.getByRole('combobox', { name: 'cashReceiptType' }); + const input = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); + + expect(checkbox).toBeInTheDocument(); + expect(select).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + }); + + test('현금영수증 신청 및 입력 테스트', async () => { + render(); + + const checkbox = screen.getByRole('checkbox', { name: '현금영수증 신청' }); + const select = screen.getByRole('combobox', { name: 'cashReceiptType' }); + const input = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); + + // 체크박스 클릭 + await userEvent.click(checkbox); + + // 옵션 선택 + await userEvent.selectOptions(select, 'BUSINESS'); + + // 입력창에 값 입력 + await userEvent.type(input, '1234567890'); + + // 입력값 확인 (react-hook-form 연동 테스트는 별도로 필요) + expect(input).toHaveValue('1234567890'); + }); +}); From 396057213a44e0cc5ecac654ee9f070bb80c2cf5 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 01:53:43 +0900 Subject: [PATCH 24/43] =?UTF-8?q?feat(components/order):=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B9=B4=EB=93=9C=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8(MessageCardFields)=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageCardFields 컴포넌트 렌더링 및 사용자 입력 테스트 추가 @testing-library/react 및 userEvent를 사용하여 테스트 구현 react-hook-form 연동 테스트는 별도로 분리하여 작성 예정 --- .../Fields/MessageCardFields.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx diff --git a/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx b/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx new file mode 100644 index 00000000..cef25746 --- /dev/null +++ b/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { MessageCardFields } from './MessageCardFields'; + +describe('MessageCardFields', () => { + test('메시지 카드 입력 컴포넌트 렌더링 확인', () => { + render(); + + const textarea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); + expect(textarea).toBeInTheDocument(); + }); + + test('메시지 카드 입력 테스트', async () => { + render(); + + const textarea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); + const message = '축하합니다! 좋은 하루 보내세요.'; + + await userEvent.type(textarea, message); + + // 입력값 확인 (react-hook-form 연동 테스트는 별도로 필요) + expect(textarea).toHaveValue(message); + }); +}); From b278fd5202b74d9140d4ed45b074c279dc417a4a Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 01:56:24 +0900 Subject: [PATCH 25/43] =?UTF-8?q?docs:=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf3bc879..f6d36663 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,12 @@ ### 단위 테스트 작성 -- [ ] 컴포넌트 별 단위 테스트 작성 -- [ ] 훅(hooks) 단위 테스트 작성 +- CashReceiptFields 컴포넌트 + - [x] 렌더링 테스트: 현금영수증 관련 입력 컴포넌트(체크박스, 셀렉트, input)가 화면에 정상적으로 표시되는지 확인 + - [x] 사용자 상호작용 테스트: 체크박스 클릭, 셀렉트 옵션 선택, input 값 입력 등 사용자 입력에 대한 테스트 +- MessageCardFields 컴포넌트 + - [x] 렌더링 테스트: 메시지 카드 입력 textarea가 화면에 정상적으로 표시되는지 확인 + - [x] 사용자 입력 테스트: textarea에 메시지 입력 후 값이 제대로 반영되는지 확인 ### 통합 테스트 작성 From 33d8b9a883b753a23e33c6f155b755fa5e87d1b6 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:31:53 +0900 Subject: [PATCH 26/43] =?UTF-8?q?test:=20useGetCategories=20=ED=9B=85?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React Query를 사용한 카테고리 데이터 fetching 테스트 - 모의 데이터와 실제 반환 데이터 비교 검증 - TypeScript 타입 오류 수정 및 타입 안정성 개선 --- package-lock.json | 65 ++++++++++++++++++++++---- package.json | 3 +- src/api/hooks/useGetCategorys.test.tsx | 19 ++++++++ src/api/hooks/useGetProducts.test.ts | 33 ------------- src/mocks/setupTests.ts | 7 +++ 5 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 src/api/hooks/useGetCategorys.test.tsx delete mode 100644 src/api/hooks/useGetProducts.test.ts create mode 100644 src/mocks/setupTests.ts diff --git a/package-lock.json b/package-lock.json index 28d271ef..5cc2c59a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", - "@tanstack/react-query": "^5.24.1", "axios": "^1.7.2", "framer-motion": "^11.0.6", "react": "^18.2.0", @@ -35,8 +34,10 @@ "@storybook/react": "^7.6.17", "@storybook/react-webpack5": "^7.6.17", "@storybook/test": "^7.6.17", + "@tanstack/react-query": "^5.51.11", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", @@ -11145,20 +11146,22 @@ "dev": true }, "node_modules/@tanstack/query-core": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz", - "integrity": "sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==", + "version": "5.51.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.9.tgz", + "integrity": "sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==", + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.24.1.tgz", - "integrity": "sha512-4+09JEdO4d6+Gc8Y/g2M/MuxDK5IY0QV8+2wL2304wPKJgJ54cBbULd3nciJ5uvh/as8rrxx6s0mtIwpRuGd1g==", + "version": "5.51.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.11.tgz", + "integrity": "sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==", + "dev": true, "dependencies": { - "@tanstack/query-core": "5.24.1" + "@tanstack/query-core": "5.51.9" }, "funding": { "type": "github", @@ -11328,6 +11331,52 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, + "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/@testing-library/react/node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", diff --git a/package.json b/package.json index ed1d8013..b9cde0af 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", - "@tanstack/react-query": "^5.24.1", "axios": "^1.7.2", "framer-motion": "^11.0.6", "react": "^18.2.0", @@ -53,8 +52,10 @@ "@storybook/react": "^7.6.17", "@storybook/react-webpack5": "^7.6.17", "@storybook/test": "^7.6.17", + "@tanstack/react-query": "^5.51.11", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", diff --git a/src/api/hooks/useGetCategorys.test.tsx b/src/api/hooks/useGetCategorys.test.tsx new file mode 100644 index 00000000..9ada7f82 --- /dev/null +++ b/src/api/hooks/useGetCategorys.test.tsx @@ -0,0 +1,19 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { CATEGORIES_RESPONSE_DATA } from './categories.mock'; +import { useGetCategories } from './useGetCategorys'; +describe('useGetCategories', () => { + it('should fetch categories correctly', async () => { + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useGetCategories(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(CATEGORIES_RESPONSE_DATA); + }); +}); diff --git a/src/api/hooks/useGetProducts.test.ts b/src/api/hooks/useGetProducts.test.ts deleted file mode 100644 index 850a1f4a..00000000 --- a/src/api/hooks/useGetProducts.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getProducts } from './useGetProducts'; - -// Mocking the PRODUCTS_MOCK_DATA -jest.mock('./products.mock', () => ({ - PRODUCTS_MOCK_DATA: { - content: [ - { id: 1, name: 'Product 1' }, - { id: 2, name: 'Product 2' }, - ], - last: false, - number: 0, - size: 20, - totalElements: 40, - }, -})); - -describe('getProducts', () => { - it('should return products data', async () => { - const data = await getProducts(); - - expect(data).toEqual({ - products: [ - { id: 1, name: 'Product 1' }, - { id: 2, name: 'Product 2' }, - ], - nextPageToken: '1', - pageInfo: { - totalResults: 40, - resultsPerPage: 20, - }, - }); - }); -}); diff --git a/src/mocks/setupTests.ts b/src/mocks/setupTests.ts new file mode 100644 index 00000000..7caca389 --- /dev/null +++ b/src/mocks/setupTests.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; + +import { worker } from './browser'; + +beforeAll(() => worker.start()); +afterEach(() => worker.resetHandlers()); +afterAll(() => worker.stop()); From 4842d8242932ade8782456e59e706237efa60440 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:33:25 +0900 Subject: [PATCH 27/43] =?UTF-8?q?test:=20useGetProducts=20=ED=9B=85?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React Query를 사용한 상품 데이터 fetching 테스트 구현 - 초기 데이터 로딩 및 반환 데이터 검증 - 페이지네이션 기능 테스트 추가 - 모의 데이터와 실제 반환 데이터 비교 검증 --- src/api/hooks/useGetProducts.test.tsx | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/api/hooks/useGetProducts.test.tsx diff --git a/src/api/hooks/useGetProducts.test.tsx b/src/api/hooks/useGetProducts.test.tsx new file mode 100644 index 00000000..dd189a24 --- /dev/null +++ b/src/api/hooks/useGetProducts.test.tsx @@ -0,0 +1,58 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { PRODUCTS_MOCK_DATA } from './products.mock'; +import { useGetProducts } from './useGetProducts'; + +describe('useGetProducts', () => { + it('should fetch products correctly', async () => { + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetProducts({ categoryId: '1', maxResults: 20 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.pages[0]).toEqual({ + products: PRODUCTS_MOCK_DATA.content, + nextPageToken: + PRODUCTS_MOCK_DATA.last === false ? (PRODUCTS_MOCK_DATA.number + 1).toString() : undefined, + pageInfo: { + totalResults: PRODUCTS_MOCK_DATA.totalElements, + resultsPerPage: PRODUCTS_MOCK_DATA.size, + }, + }); + }); + + it('should handle pagination correctly', async () => { + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetProducts({ categoryId: '1', maxResults: 20 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Fetch next page + if (result.current.hasNextPage) { + await result.current.fetchNextPage(); + + await waitFor(() => expect(result.current.data?.pages.length).toBe(2)); + } + + // Check if the nextPageToken is handled correctly + expect(result.current.data?.pages[1]?.nextPageToken).toBe( + PRODUCTS_MOCK_DATA.last === false ? (PRODUCTS_MOCK_DATA.number + 2).toString() : undefined, + ); + }); +}); From 91ed3a4103d13a4f737e9cd3516b0bf2e99812ac Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:36:16 +0900 Subject: [PATCH 28/43] =?UTF-8?q?test:=20useGetProductDetail=20=ED=9B=85?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MSW를 사용한 API 요청 모킹 설정 - 상품 상세 정보 fetching 성공 케이스 테스트 - API 에러 응답 처리 테스트 - React Query와 Suspense를 활용한 비동기 로직 검증 --- src/api/hooks/useGetProductDetail.test.ts | 31 -------- src/api/hooks/useGetProductDetail.test.tsx | 83 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 31 deletions(-) delete mode 100644 src/api/hooks/useGetProductDetail.test.ts create mode 100644 src/api/hooks/useGetProductDetail.test.tsx diff --git a/src/api/hooks/useGetProductDetail.test.ts b/src/api/hooks/useGetProductDetail.test.ts deleted file mode 100644 index a41ac493..00000000 --- a/src/api/hooks/useGetProductDetail.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import axios from 'axios'; - -import type { ProductDetailResponseData } from './productDetail.mock'; -import { getProductDetailPath } from './productDetailPath'; -import { getProductDetail } from './useGetProductDetail'; - -// Mocking axios -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -// Sample product detail response -const mockProductDetailResponse: ProductDetailResponseData = { - id: 1, - name: 'string', - price: 999, - imageUrl: 'string', - categoryId: 999 -}; - -describe('getProductDetail', () => { - it('should return product detail data', async () => { - // Mocking axios.get to return the mock response - mockedAxios.get.mockResolvedValueOnce({ data: mockProductDetailResponse }); - - const productId = '1'; - const data = await getProductDetail({ productId }); - - expect(axios.get).toHaveBeenCalledWith(getProductDetailPath(productId)); - expect(data).toEqual(mockProductDetailResponse); - }); -}); diff --git a/src/api/hooks/useGetProductDetail.test.tsx b/src/api/hooks/useGetProductDetail.test.tsx new file mode 100644 index 00000000..497a025c --- /dev/null +++ b/src/api/hooks/useGetProductDetail.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import React, { Suspense } from 'react'; + +import { getProductDetailPath } from './productDetailPath'; +import { useGetProductDetail } from './useGetProductDetail'; + +// 모의 데이터 +const mockProductDetail = { + id: '1', + name: 'Test Product', + description: 'This is a test product', + price: 9.99, +}; + +// MSW 서버 설정 +const server = setupServer( + rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + return res(ctx.json(mockProductDetail)); + }), +); + +describe('useGetProductDetail', () => { + // 테스트 전에 MSW 서버 시작 + beforeAll(() => server.listen()); + // 각 테스트 후에 핸들러 리셋 + afterEach(() => server.resetHandlers()); + // 모든 테스트 후에 서버 종료 + afterAll(() => server.close()); + + it('should fetch product detail correctly', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + Loading...}>{children} + + ); + + const { result } = renderHook(() => useGetProductDetail({ productId: '1' }), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockProductDetail); + }); + + it('should handle error correctly', async () => { + // 에러 응답을 위한 서버 핸들러 오버라이드 + server.use( + rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + return res(ctx.status(500)); + }), + ); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + Loading...}>{children} + + ); + + const { result } = renderHook(() => useGetProductDetail({ productId: '1' }), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBeDefined(); + }); +}); From 8c30ec44021827c7341271aec90880ea4435d165 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:39:25 +0900 Subject: [PATCH 29/43] =?UTF-8?q?test:=20useGetProductOptions=20=ED=9B=85?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSW(Mock Service Worker)를 사용하여 API 요청을 모의합니다. React Query의 QueryClient를 생성합니다. QueryClientProvider로 훅을 감싸는 래퍼 컴포넌트를 만듭니다. renderHook을 사용하여 useGetProductOptions 훅을 렌더링합니다. waitFor를 사용하여 쿼리가 성공적으로 완료될 때까지 기다립니다. 반환된 데이터가 모의 데이터와 일치하는지 확인합니다. productId가 제공되지 않았을 때 쿼리가 실행되지 않는지 확인합니다. --- src/api/hooks/useGetProductOptions.test.tsx | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/api/hooks/useGetProductOptions.test.tsx diff --git a/src/api/hooks/useGetProductOptions.test.tsx b/src/api/hooks/useGetProductOptions.test.tsx new file mode 100644 index 00000000..76096d8e --- /dev/null +++ b/src/api/hooks/useGetProductOptions.test.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import React from 'react'; + +import { getProductOptionsPath } from './productOptionsPath'; +import { useGetProductOptions } from './useGetProductOptions'; + +// 모의 데이터 +const mockProductOptions = { + options: [ + { id: '1', name: 'Size', values: ['S', 'M', 'L'] }, + { id: '2', name: 'Color', values: ['Red', 'Blue', 'Green'] }, + ], +}; + +// MSW 서버 설정 +const server = setupServer( + rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + return res(ctx.json(mockProductOptions)); + }), +); + +describe('useGetProductOptions', () => { + // 테스트 전에 MSW 서버 시작 + beforeAll(() => server.listen()); + // 각 테스트 후에 핸들러 리셋 + afterEach(() => server.resetHandlers()); + // 모든 테스트 후에 서버 종료 + afterAll(() => server.close()); + + it('should fetch product options correctly', async () => { + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockProductOptions); + }); + + it('should handle error correctly', async () => { + // 에러 응답을 위한 서버 핸들러 오버라이드 + server.use( + rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + return res(ctx.status(500)); + }), + ); + + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toBeDefined(); + }); +}); From c2884580ff94e2dda44205bea8ee3716feda9517 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:42:56 +0900 Subject: [PATCH 30/43] =?UTF-8?q?docs:=20mock=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6d36663..a1677f4a 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,10 @@ ### 통합 테스트 작성 - 상품 상세 페이지 - - [ ] 상품 상세 정보 로딩 테스트 - - [ ] 옵션 선택 테스트 + - [x] useGetCategorys.test + - [x] useGetProducts.test + - [x] useGetProductDetail.test + - [x] useGetProductOptions.test - 결제하기 페이지 - [ ] 입력 필드 테스트 From 1ae0bd390c9b364553f2c21646bfc12d5bab36af Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:49:57 +0900 Subject: [PATCH 31/43] =?UTF-8?q?feat:=20=ED=98=84=EA=B8=88=EC=98=81?= =?UTF-8?q?=EC=88=98=EC=A6=9D=20=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현금영수증 신청 체크박스 상태에 따른 필드 활성화/비활성화 테스트 구현 - 체크박스가 활성화된 경우 필드 값 입력 및 유효성 검증 테스트 구현 --- README.md | 6 +- .../Fields/CashReceiptFields.test.tsx | 85 ++++++++++++------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a1677f4a..a81aafe0 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,10 @@ - [x] useGetProductOptions.test - 결제하기 페이지 - - [ ] 입력 필드 테스트 - - [ ] 버튼 클릭 테스트 - - 현금영수증 Checkbox가 `false`인 경우 현금영수증 종류, 현금영수증 번호 필드가 비활성화 되어있는지 확인하는 테스트 코드 작성 + - 현금영수증 - [ ] Checkbox 상태에 따른 필드 활성화/비활성화 테스트 - [ ] Checkbox가 `true`인 경우 필드 값 입력 테스트 - - Form의 validation 로직이 정상 동작하는지 확인하는 테스트 코드 작성 + - Form - [ ] 필수 입력 필드 검사 - [ ] 입력 값 형식 검사 diff --git a/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx b/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx index 66c8867b..bf2e051c 100644 --- a/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx +++ b/src/components/features/Order/OrderForm/Fields/CashReceiptFields.test.tsx @@ -1,39 +1,62 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { ChakraProvider } from '@chakra-ui/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; import { CashReceiptFields } from './CashReceiptFields'; describe('CashReceiptFields', () => { - test('현금영수증 관련 입력 컴포넌트 렌더링 확인', () => { - render(); - - // 컴포넌트 존재 여부 확인 - const checkbox = screen.getByRole('checkbox', { name: '현금영수증 신청' }); - const select = screen.getByRole('combobox', { name: 'cashReceiptType' }); - const input = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); - - expect(checkbox).toBeInTheDocument(); - expect(select).toBeInTheDocument(); - expect(input).toBeInTheDocument(); + const TestComponent = () => { + const methods = useForm(); + + return ( + + + + + + ); + }; + + test('Checkbox 상태에 따른 필드 활성화/비활성화 테스트', async () => { + render(); + + // 1. 초기 상태: 체크박스가 체크되지 않은 상태에서 필드들이 비활성화 되어 있는지 확인 + const cashReceiptTypeSelect = screen.getByRole('combobox', { name: 'cashReceiptType' }); + const cashReceiptNumberInput = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); + expect(cashReceiptTypeSelect).toBeDisabled(); + expect(cashReceiptNumberInput).toBeDisabled(); + + // 2. 체크박스 클릭: 체크박스를 클릭하여 필드들을 활성화 + const cashReceiptCheckbox = screen.getByLabelText('현금영수증 신청'); + fireEvent.click(cashReceiptCheckbox); + + // 3. 필드 활성화 확인: 필드들이 활성화 되었는지 확인 + expect(cashReceiptTypeSelect).toBeEnabled(); + expect(cashReceiptNumberInput).toBeEnabled(); }); - test('현금영수증 신청 및 입력 테스트', async () => { - render(); - - const checkbox = screen.getByRole('checkbox', { name: '현금영수증 신청' }); - const select = screen.getByRole('combobox', { name: 'cashReceiptType' }); - const input = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); - - // 체크박스 클릭 - await userEvent.click(checkbox); - - // 옵션 선택 - await userEvent.selectOptions(select, 'BUSINESS'); - - // 입력창에 값 입력 - await userEvent.type(input, '1234567890'); - - // 입력값 확인 (react-hook-form 연동 테스트는 별도로 필요) - expect(input).toHaveValue('1234567890'); + test('Checkbox가 true인 경우 필드 값 입력 테스트', async () => { + const methods = useForm(); + render( + + + + + , + ); + + // 1. 체크박스 클릭 + const cashReceiptCheckbox = screen.getByLabelText('현금영수증 신청'); + fireEvent.click(cashReceiptCheckbox); + + // 2. 필드 값 입력 + const cashReceiptTypeSelect = screen.getByRole('combobox', { name: 'cashReceiptType' }); + const cashReceiptNumberInput = screen.getByPlaceholderText('(-없이) 숫자만 입력해주세요.'); + fireEvent.change(cashReceiptTypeSelect, { target: { value: 'BUSINESS' } }); + fireEvent.change(cashReceiptNumberInput, { target: { value: '1234567890' } }); + + // 3. 입력 값 확인 + expect(methods.getValues('cashReceiptType')).toBe('BUSINESS'); + expect(methods.getValues('cashReceiptNumber')).toBe('1234567890'); }); }); From 9826bdb805a4ee8ed617facac435cc685f9690af Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 02:51:35 +0900 Subject: [PATCH 32/43] =?UTF-8?q?test:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지 카드 텍스트 필드 필수 입력 및 최대 길이 초과 입력 검증 테스트 구현 - 정상 입력 시 폼 제출 및 값 전달 확인 테스트 구현 --- README.md | 12 +-- .../Fields/MessageCardFields.test.tsx | 74 +++++++++++++++---- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a81aafe0..acf6f15d 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ ### MSW를 사용하여 Mock API 설정 -- [x] 상세 API 엔드포인트 추가 -- [x] 옵션 API 엔드포인트 추가 +- [ ] 상세 API 엔드포인트 추가 +- [ ] 옵션 API 엔드포인트 추가 ### 단위 테스트 작성 @@ -43,11 +43,11 @@ - 결제하기 페이지 - 현금영수증 - - [ ] Checkbox 상태에 따른 필드 활성화/비활성화 테스트 - - [ ] Checkbox가 `true`인 경우 필드 값 입력 테스트 + - [x] Checkbox 상태에 따른 필드 활성화/비활성화 테스트 + - [x] Checkbox가 `true`인 경우 필드 값 입력 테스트 - Form - - [ ] 필수 입력 필드 검사 - - [ ] 입력 값 형식 검사 + - [x] 필수 입력 필드 검사 + - [x] 입력 값 형식 검사 --- diff --git a/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx b/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx index cef25746..7c20abd2 100644 --- a/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx +++ b/src/components/features/Order/OrderForm/Fields/MessageCardFields.test.tsx @@ -1,25 +1,71 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { ChakraProvider } from '@chakra-ui/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; -import { MessageCardFields } from './MessageCardFields'; +import { MessageCardFields } from './MessageCardFields'; // 컴포넌트 경로에 맞게 수정 describe('MessageCardFields', () => { - test('메시지 카드 입력 컴포넌트 렌더링 확인', () => { - render(); + // 폼 제출 함수 (실제 폼 제출 로직은 생략) + const handleSubmit = jest.fn(); - const textarea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); - expect(textarea).toBeInTheDocument(); + const TestComponent = () => { + const methods = useForm(); + + return ( + + +
+ + + +
+
+ ); + }; + + test('필수 입력 필드 검사', async () => { + render(); + + // 1. 폼 제출 시도 + const submitButton = screen.getByText('제출'); + fireEvent.click(submitButton); + + // 2. 에러 메시지 확인 + const errorMessage = await screen.findByText('메시지를 입력해주세요.'); // 실제 에러 메시지에 맞게 수정 + expect(errorMessage).toBeInTheDocument(); + }); + + test('입력 값 형식 검사 (최대 길이 초과)', async () => { + render(); + + // 1. 텍스트 입력 (최대 길이 초과) + const textArea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); + fireEvent.change(textArea, { target: { value: 'a'.repeat(501) } }); // 최대 길이 가정: 500자 + + // 2. 폼 제출 시도 + const submitButton = screen.getByText('제출'); + fireEvent.click(submitButton); + + // 3. 에러 메시지 확인 + const errorMessage = await screen.findByText('메시지는 500자를 넘을 수 없습니다.'); // 실제 에러 메시지에 맞게 수정 + expect(errorMessage).toBeInTheDocument(); }); - test('메시지 카드 입력 테스트', async () => { - render(); + test('정상 입력 검사', async () => { + render(); - const textarea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); - const message = '축하합니다! 좋은 하루 보내세요.'; + // 1. 텍스트 입력 (정상) + const textArea = screen.getByPlaceholderText('선물과 함께 보낼 메시지를 적어보세요'); + fireEvent.change(textArea, { target: { value: '테스트 메시지' } }); - await userEvent.type(textarea, message); + // 2. 폼 제출 + const submitButton = screen.getByText('제출'); + fireEvent.click(submitButton); - // 입력값 확인 (react-hook-form 연동 테스트는 별도로 필요) - expect(textarea).toHaveValue(message); + // 3. 폼 제출 확인 및 값 전달 확인 + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(handleSubmit).toHaveBeenCalledWith({ + messageCardTextMessage: '테스트 메시지', + }); }); }); From ec2703b25ab94d6fb59a1a35fdb811ad9861d881 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 03:37:57 +0900 Subject: [PATCH 33/43] =?UTF-8?q?docs:=20step2=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index acf6f15d..a7d47604 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,21 @@ ## 2단계 - 로그인, 관심 상품 등록 / 삭제, 관심 목록 구현 -- 로그인 기능을 구현합니다. -- 회원가입 화면을 만들고, 회원가입 기능이 동작하도록 구현합니다. (회원가입을 하면 로그인이 되도록 합니다.) - - 회원가입 버튼은 로그인 화면 하단에 배치합니다. 로그인 화면을 그대로 사용해도 괜찮습니다. -- 상품 상세 페이지에서 관심 등록 버튼을 만듭니다. - - 상품 상세 페이지에서 관심 버튼을 클릭했을 때 관심 추가가 동작하도록 합니다. - - 관심 등록 성공 시 Alert로 "관심 등록 완료" 메시지를 노출합니다. -- 마이 페이지에서 관심 목록 리스트를 만듭니다. - - 관심 목록 리스트는 Chakra UI를 사용하여 자유롭게 만듭니다. - - 관심 목록 API는 카카오테크 선물하기 API 노션의 response 데이터를 사용합니다. - - 관심 목록 리스트에서 관심 삭제가 가능하도록 합니다. - - 관심 삭제 시 목록에서 사라집니다. -- 본인만의 기준으로 일관된 코드를 작성합니다. -- 기능 단위로 나누어 커밋을 합니다. +- 계정 관리 + - [ ] 로그인 기능 구현 + - [ ] 회원가입 버튼 UI 구현: 로그인 화면 하단에 회원가입 버튼 배치 + - [ ] 회원가입 버튼 로직 구현: 버튼 클릭 시 회원가입 페이지로 이동 + - [ ] 회원가입 UI 구현: 로그인 UI 참고 및 사용 + - [ ] 회원가입 로직 구현: 회원가입 성공 시 로그인 페이지로 이동 및 성공 메시지 표시 + +- 상품 상세 페이지 + - [ ] 관심 등록 버튼 UI 구현 + - [ ]관심 등록 버튼 로직 구현: 관심 등록 성공 시 "관심 등록 완료" Alert 메시지 표시 + +- 마이 페이지 + - [ ]관심 목록 리스트 UI 구현: Chakra UI 컴포넌트 활용 + - [ ]관심 목록 API 활용: 선물하기 API 노션의 response 데이터 활용 + - [ ]관심 목록 리스트 로직 구현: 관심 삭제 성공 시 해당 항목 리스트에서 제거 --- From da347d0a9ee4bdf837d1c5518e5f00018c5775d6 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 11:48:48 +0900 Subject: [PATCH 34/43] fix: Remove req --- src/api/hooks/useGetProductDetail.test.tsx | 30 ++++++++++----------- src/api/hooks/useGetProductOptions.test.tsx | 4 +-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/api/hooks/useGetProductDetail.test.tsx b/src/api/hooks/useGetProductDetail.test.tsx index 497a025c..7349afc7 100644 --- a/src/api/hooks/useGetProductDetail.test.tsx +++ b/src/api/hooks/useGetProductDetail.test.tsx @@ -4,25 +4,23 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import React, { Suspense } from 'react'; -import { getProductDetailPath } from './productDetailPath'; -import { useGetProductDetail } from './useGetProductDetail'; +import { getProductOptionsPath } from './productOptionsPath'; +import { useGetProductOptions } from './useGetProductOptions'; // 모의 데이터 -const mockProductDetail = { - id: '1', - name: 'Test Product', - description: 'This is a test product', - price: 9.99, -}; +const mockProductOptions = [ + { id: '1', name: 'Option 1', price: 10 }, + { id: '2', name: 'Option 2', price: 20 }, +]; // MSW 서버 설정 const server = setupServer( - rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { - return res(ctx.json(mockProductDetail)); + rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { + return res(ctx.json(mockProductOptions)); }), ); -describe('useGetProductDetail', () => { +describe('useGetProductOptions', () => { // 테스트 전에 MSW 서버 시작 beforeAll(() => server.listen()); // 각 테스트 후에 핸들러 리셋 @@ -30,7 +28,7 @@ describe('useGetProductDetail', () => { // 모든 테스트 후에 서버 종료 afterAll(() => server.close()); - it('should fetch product detail correctly', async () => { + it('should fetch product options correctly', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -45,17 +43,17 @@ describe('useGetProductDetail', () => { ); - const { result } = renderHook(() => useGetProductDetail({ productId: '1' }), { wrapper }); + const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual(mockProductDetail); + expect(result.current.data).toEqual(mockProductOptions); }); it('should handle error correctly', async () => { // 에러 응답을 위한 서버 핸들러 오버라이드 server.use( - rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { return res(ctx.status(500)); }), ); @@ -74,7 +72,7 @@ describe('useGetProductDetail', () => { ); - const { result } = renderHook(() => useGetProductDetail({ productId: '1' }), { wrapper }); + const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); await waitFor(() => expect(result.current.isError).toBe(true)); diff --git a/src/api/hooks/useGetProductOptions.test.tsx b/src/api/hooks/useGetProductOptions.test.tsx index 76096d8e..34a13666 100644 --- a/src/api/hooks/useGetProductOptions.test.tsx +++ b/src/api/hooks/useGetProductOptions.test.tsx @@ -17,7 +17,7 @@ const mockProductOptions = { // MSW 서버 설정 const server = setupServer( - rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { return res(ctx.json(mockProductOptions)); }), ); @@ -47,7 +47,7 @@ describe('useGetProductOptions', () => { it('should handle error correctly', async () => { // 에러 응답을 위한 서버 핸들러 오버라이드 server.use( - rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { return res(ctx.status(500)); }), ); From 18366d0cf2c206ab6d5912e4a30acf20c17dd124 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 12:03:45 +0900 Subject: [PATCH 35/43] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLogin 훅을 사용하여 로그인 API 연동 - 로그인 페이지에서 아이디와 비밀번호 입력 기능 구현 - 로그인 요청 중 로딩 상태를 표시하고 버튼 비활성화 처리 - 로그인 성공 시 세션 저장소에 토큰 저장 및 리다이렉트 - 로그인 실패 시 오류 메시지 알림 처리 --- src/api/hooks/login.mock.ts | 32 ++++++++++++++++++++++++++++++++ src/api/hooks/useLogin.ts | 37 +++++++++++++++++++++++++++++++++++++ src/pages/Login/index.tsx | 27 +++++++++++++++++---------- 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/api/hooks/login.mock.ts create mode 100644 src/api/hooks/useLogin.ts diff --git a/src/api/hooks/login.mock.ts b/src/api/hooks/login.mock.ts new file mode 100644 index 00000000..dffbd44a --- /dev/null +++ b/src/api/hooks/login.mock.ts @@ -0,0 +1,32 @@ +import { rest } from 'msw'; + +const BASE_URL = 'http://localhost:3000'; + +type LoginRequestBody = { + email: string; + password: string; +}; + +type LoginSuccessResponse = { + email: string; + token: string; +}; + +const VALID_EMAIL = 'user@example.com'; +const VALID_PASSWORD = 'password123'; + +export const loginMockHandler = [ + rest.post(`${BASE_URL}/api/members/login`, (req, res, ctx) => { + const { email, password } = req.body as LoginRequestBody; + + if (email === VALID_EMAIL && password === VALID_PASSWORD) { + const response: LoginSuccessResponse = { + email, + token: 'mocked-jwt-token', + }; + return res(ctx.status(200), ctx.json(response)); + } else { + return res(ctx.status(403), ctx.json({ message: 'Invalid email or password' })); + } + }), +]; diff --git a/src/api/hooks/useLogin.ts b/src/api/hooks/useLogin.ts new file mode 100644 index 00000000..292390c3 --- /dev/null +++ b/src/api/hooks/useLogin.ts @@ -0,0 +1,37 @@ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; + +type LoginRequestBody = { + email: string; + password: string; +}; + +type LoginSuccessResponse = { + email: string; + token: string; +}; + +const BASE_URL = 'http://localhost:3000'; + +const login = async (loginData: LoginRequestBody): Promise => { + const response = await fetch(`${BASE_URL}/api/members/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(loginData), + }); + + if (!response.ok) { + throw new Error('Invalid email or password'); + } + + return response.json(); +}; + +export const useLogin = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: login, + ...options, + }); +}; diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 28c095f8..934a77a5 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { useLogin } from '@/api/hooks/useLogin'; // useLogin 훅을 불러옵니다. import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; @@ -14,24 +15,29 @@ export const LoginPage = () => { const [password, setPassword] = useState(''); const [queryParams] = useSearchParams(); + const { mutate: login, status } = useLogin({ + onSuccess: (data) => { + authSessionStorage.set(data.token); + const redirectUrl = queryParams.get('redirect') ?? `${window.location.origin}/`; + window.location.replace(redirectUrl); + }, + onError: () => { + alert('아이디 또는 비밀번호가 잘못되었습니다.'); + }, + }); + const handleConfirm = () => { if (!id || !password) { alert('아이디와 비밀번호를 입력해주세요.'); return; } - // TODO: API 연동 - - // TODO: API 연동 전까지 임시 로그인 처리 - authSessionStorage.set(id); - - const redirectUrl = queryParams.get('redirect') ?? `${window.location.origin}/`; - return window.location.replace(redirectUrl); + login({ email: id, password }); }; return ( - + setId(e.target.value)} /> @@ -41,14 +47,15 @@ export const LoginPage = () => { value={password} onChange={(e) => setPassword(e.target.value)} /> - - + ); From 689ef92b9a5d41fdc9307c3dc5fe7b68c03cf7d0 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 12:21:07 +0900 Subject: [PATCH 36/43] =?UTF-8?q?feat(login):=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EB=B2=84=ED=8A=BC=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/{useLogin.ts => useGetLogin.ts} | 2 +- src/pages/Login/index.tsx | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) rename src/api/hooks/{useLogin.ts => useGetLogin.ts} (87%) diff --git a/src/api/hooks/useLogin.ts b/src/api/hooks/useGetLogin.ts similarity index 87% rename from src/api/hooks/useLogin.ts rename to src/api/hooks/useGetLogin.ts index 292390c3..9ed78670 100644 --- a/src/api/hooks/useLogin.ts +++ b/src/api/hooks/useGetLogin.ts @@ -29,7 +29,7 @@ const login = async (loginData: LoginRequestBody): Promise return response.json(); }; -export const useLogin = (options?: UseMutationOptions) => { +export const useGetLogin = (options?: UseMutationOptions) => { return useMutation({ mutationFn: login, ...options, diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 934a77a5..a86544f0 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,8 +1,8 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; -import { useLogin } from '@/api/hooks/useLogin'; // useLogin 훅을 불러옵니다. +import { useLogin } from '@/api/hooks/useLogin'; import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; @@ -39,7 +39,11 @@ export const LoginPage = () => { - setId(e.target.value)} /> + setId(e.target.value)} + /> { sm: 60, }} /> - + + 회원가입 + ); @@ -85,3 +92,16 @@ const FormWrapper = styled.article` padding: 60px 52px; } `; + +const SignUpLinkWrapper = styled.div` + margin-top: 16px; + text-align: center; +`; + +const SignUpLink = styled(Link)` + color: #1a73e8; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; From 526e094a34148a7d3ab7bbc983d757294d8eb93e Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 12:21:59 +0900 Subject: [PATCH 37/43] =?UTF-8?q?feat(router):=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Signup/index.ts | 19 +++++++++++++++++++ src/routes/index.tsx | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 src/pages/Signup/index.ts diff --git a/src/pages/Signup/index.ts b/src/pages/Signup/index.ts new file mode 100644 index 00000000..40f44395 --- /dev/null +++ b/src/pages/Signup/index.ts @@ -0,0 +1,19 @@ +import styled from '@emotion/styled'; + +export const SignupPage = () => { + return ( + +

회원가입 페이지

+ {/* 회원가입 폼 내용을 여기에 추가합니다 */} +
+ ); +}; + +const Wrapper = styled.div` + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +`; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index e7c59cdf..0e6b216d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -7,6 +7,7 @@ import { HomePage } from '@/pages/Home'; import { LoginPage } from '@/pages/Login'; import { MyAccountPage } from '@/pages/MyAccount'; import { OrderPage } from '@/pages/Order'; +import { SignupPage } from '@/pages/Signup'; // 회원가입 페이지 추가 import { PrivateRoute } from './components/PrivateRoute'; import { RouterPath } from './path'; @@ -58,6 +59,10 @@ const router = createBrowserRouter([ path: RouterPath.login, element: , }, + { + path: RouterPath.signup, + element: , + }, ]); export const Routes = () => { From 22925be443822c209dd291b1ec9f95f01a5a3109 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 12:25:36 +0900 Subject: [PATCH 38/43] =?UTF-8?q?feat(signup):=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +-- src/pages/Login/index.tsx | 8 ++-- src/pages/Signup/index.ts | 81 ++++++++++++++++++++++++++++++++++++++- src/routes/index.tsx | 2 +- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a7d47604..c0dbdaa3 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ ## 2단계 - 로그인, 관심 상품 등록 / 삭제, 관심 목록 구현 - 계정 관리 - - [ ] 로그인 기능 구현 - - [ ] 회원가입 버튼 UI 구현: 로그인 화면 하단에 회원가입 버튼 배치 - - [ ] 회원가입 버튼 로직 구현: 버튼 클릭 시 회원가입 페이지로 이동 + - [x] 로그인 기능 구현 + - [x] 회원가입 버튼 UI 구현: 로그인 화면 하단에 회원가입 버튼 배치 + - [x] 회원가입 버튼 로직 구현: 버튼 클릭 시 회원가입 페이지로 이동 - [ ] 회원가입 UI 구현: 로그인 UI 참고 및 사용 - [ ] 회원가입 로직 구현: 회원가입 성공 시 로그인 페이지로 이동 및 성공 메시지 표시 diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index a86544f0..84b0d391 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; -import { useLogin } from '@/api/hooks/useLogin'; +import { useGetLogin } from '@/api/hooks/useGetLogin'; import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; @@ -15,7 +15,7 @@ export const LoginPage = () => { const [password, setPassword] = useState(''); const [queryParams] = useSearchParams(); - const { mutate: login, status } = useLogin({ + const { mutate: login } = useGetLogin({ onSuccess: (data) => { authSessionStorage.set(data.token); const redirectUrl = queryParams.get('redirect') ?? `${window.location.origin}/`; @@ -57,9 +57,7 @@ export const LoginPage = () => { sm: 60, }} /> - + 회원가입 diff --git a/src/pages/Signup/index.ts b/src/pages/Signup/index.ts index 40f44395..4702647c 100644 --- a/src/pages/Signup/index.ts +++ b/src/pages/Signup/index.ts @@ -1,10 +1,63 @@ import styled from '@emotion/styled'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/common/Button'; +import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; +import { Spacing } from '@/components/common/layouts/Spacing'; +import { breakpoints } from '@/styles/variants'; export const SignupPage = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const handleSignup = () => { + if (!email || !password || !confirmPassword) { + alert('모든 필드를 입력해주세요.'); + return; + } + + if (password !== confirmPassword) { + alert('비밀번호가 일치하지 않습니다.'); + return; + } + + // 회원가입 로직을 추가합니다. + }; + return ( -

회원가입 페이지

- {/* 회원가입 폼 내용을 여기에 추가합니다 */} + + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + setConfirmPassword(e.target.value)} + /> + + + + 로그인 + +
); }; @@ -17,3 +70,27 @@ const Wrapper = styled.div` align-items: center; flex-direction: column; `; + +const FormWrapper = styled.article` + width: 100%; + max-width: 580px; + padding: 16px; + + @media screen and (min-width: ${breakpoints.sm}) { + border: 1px solid rgba(0, 0, 0, 0.12); + padding: 60px 52px; + } +`; + +const LoginLinkWrapper = styled.div` + margin-top: 16px; + text-align: center; +`; + +const LoginLink = styled(Link)` + color: #1a73e8; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0e6b216d..9fdfc215 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -7,7 +7,7 @@ import { HomePage } from '@/pages/Home'; import { LoginPage } from '@/pages/Login'; import { MyAccountPage } from '@/pages/MyAccount'; import { OrderPage } from '@/pages/Order'; -import { SignupPage } from '@/pages/Signup'; // 회원가입 페이지 추가 +import { SignupPage } from '@/pages/Signup'; import { PrivateRoute } from './components/PrivateRoute'; import { RouterPath } from './path'; From 90cbb38d4bc03b671e89a5e319a2c649e53d0c15 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 12:27:29 +0900 Subject: [PATCH 39/43] =?UTF-8?q?feat(api):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20useRegister=20=ED=9B=85=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/useRegister.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/api/hooks/useRegister.ts diff --git a/src/api/hooks/useRegister.ts b/src/api/hooks/useRegister.ts new file mode 100644 index 00000000..7e1ff1fb --- /dev/null +++ b/src/api/hooks/useRegister.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@tanstack/react-query'; + +type RegisterRequestBody = { + email: string; + password: string; +}; + +type RegisterResponseBody = { + email: string; + token: string; +}; + +const register = async (registerData: RegisterRequestBody): Promise => { + const response = await fetch('http://localhost:3000/api/members/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(registerData), + }); + + if (!response.ok) { + throw new Error('Invalid input'); + } + + return response.json(); +}; + +export const useRegister = () => { + return useMutation(register); +}; From 786b62dc9736fc9cb4cc7c17a584f1df252dc881 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 13:04:58 +0900 Subject: [PATCH 40/43] =?UTF-8?q?fest:=20=ED=83=80=EC=9E=85=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20MSW=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ESLint 경고(@typescript-eslint/no-var-requires) 해결을 위해 require 문을 동적 import로 변경 - 개발 환경에서만 MSW를 초기화하도록 조건부 로직 추가 - MSW 초기화 로직을 async 함수로 분리하여 코드 가독성 및 유지보수성 향상 - MSW 초기화 시 콘솔 로그 추가로 개발 편의성 증대 --- src/App.tsx | 15 ++++++++++ src/api/hooks/register.mock.ts | 31 +++++++++++++++++++ src/api/hooks/useGetRegister.ts | 38 ++++++++++++++++++++++++ src/api/hooks/useRegister.ts | 31 ------------------- src/mocks/browser.ts | 7 +++-- src/pages/Signup/{index.ts => index.tsx} | 27 +++++++++++++++-- src/routes/path.ts | 1 + 7 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 src/api/hooks/register.mock.ts create mode 100644 src/api/hooks/useGetRegister.ts delete mode 100644 src/api/hooks/useRegister.ts rename src/pages/Signup/{index.ts => index.tsx} (75%) diff --git a/src/App.tsx b/src/App.tsx index 24715e67..8f8a2205 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,11 +5,26 @@ import { queryClient } from './api/instance'; import { AuthProvider } from './provider/Auth'; import { Routes } from './routes'; +const initializeMocks = async () => { + if (process.env.NODE_ENV === 'development') { + const { worker } = await import('./mocks/browser'); + worker.start().then(() => { + console.log('Mock Service Worker is running'); + }); + } +}; + +initializeMocks(); + const App = () => { return ( + // ChakraProvider wraps the app to provide Chakra UI components and theme + {/* QueryClientProvider provides React Query context for managing server state */} + {/* AuthProvider provides authentication context to the app */} + {/* Routes component handles the routing of the application */} diff --git a/src/api/hooks/register.mock.ts b/src/api/hooks/register.mock.ts new file mode 100644 index 00000000..a694a566 --- /dev/null +++ b/src/api/hooks/register.mock.ts @@ -0,0 +1,31 @@ +import { rest } from 'msw'; + +const BASE_URL = 'http://localhost:3000'; + +interface RegisterRequestBody { + email: string; + password: string; +} + +interface RegisterSuccessResponse { + email: string; + token: string; +} + +export const registerMockHandler = [ + rest.post(`${BASE_URL}/api/members/register`, (req, res, ctx) => { + const { email, password } = req.body; + + // Basic validation (add more robust validation as needed) + if (!email || !password) { + return res(ctx.status(400), ctx.json({ message: 'Invalid input' })); + } + + // Simulate successful registration + const response: RegisterSuccessResponse = { + email, + token: 'mocked-registration-token', + }; + return res(ctx.status(201), ctx.json(response)); + }), +]; diff --git a/src/api/hooks/useGetRegister.ts b/src/api/hooks/useGetRegister.ts new file mode 100644 index 00000000..b1633be3 --- /dev/null +++ b/src/api/hooks/useGetRegister.ts @@ -0,0 +1,38 @@ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; + +const BASE_URL = 'http://localhost:3000'; + +interface RegisterRequestBody { + email: string; + password: string; +} + +interface RegisterSuccessResponse { + email: string; + token: string; +} + +const register = async (registerData: RegisterRequestBody): Promise => { + const response = await fetch(`${BASE_URL}/api/members/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(registerData), + }); + + if (!response.ok) { + const errorData = await response.json(); // Get error details from the response + throw new Error(errorData.message || 'Registration failed'); // Throw a specific error + } + + return response.json(); +}; + +export const useGetRegister = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: register, + ...options, + }); +}; diff --git a/src/api/hooks/useRegister.ts b/src/api/hooks/useRegister.ts deleted file mode 100644 index 7e1ff1fb..00000000 --- a/src/api/hooks/useRegister.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -type RegisterRequestBody = { - email: string; - password: string; -}; - -type RegisterResponseBody = { - email: string; - token: string; -}; - -const register = async (registerData: RegisterRequestBody): Promise => { - const response = await fetch('http://localhost:3000/api/members/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(registerData), - }); - - if (!response.ok) { - throw new Error('Invalid input'); - } - - return response.json(); -}; - -export const useRegister = () => { - return useMutation(register); -}; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index eaeb18cb..7f9db13b 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -1,13 +1,16 @@ import { setupWorker } from 'msw'; import { categoriesMockHandler } from '@/api/hooks/categories.mock'; +import { loginMockHandler } from '@/api/hooks/login.mock'; import { productDetailMockHandler } from '@/api/hooks/productDetail.mock'; import { productOptionsMockHandler } from '@/api/hooks/productOptions.mock'; import { productsMockHandler } from '@/api/hooks/products.mock'; - +import { registerMockHandler } from '@/api/hooks/register.mock'; export const worker = setupWorker( ...categoriesMockHandler, ...productsMockHandler, ...productDetailMockHandler, - ...productOptionsMockHandler + ...productOptionsMockHandler, + ...loginMockHandler, + ...registerMockHandler ); diff --git a/src/pages/Signup/index.ts b/src/pages/Signup/index.tsx similarity index 75% rename from src/pages/Signup/index.ts rename to src/pages/Signup/index.tsx index 4702647c..41048b4d 100644 --- a/src/pages/Signup/index.ts +++ b/src/pages/Signup/index.tsx @@ -1,15 +1,30 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; + +import { useGetRegister } from '@/api/hooks/useGetRegister'; +import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; import { Spacing } from '@/components/common/layouts/Spacing'; import { breakpoints } from '@/styles/variants'; +import { authSessionStorage } from '@/utils/storage'; export const SignupPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const navigate = useNavigate(); + + const { mutate: register } = useGetRegister({ + onSuccess: (data) => { + authSessionStorage.set(data.token); + navigate('/'); + }, + onError: (error) => { + alert(`회원가입 실패: ${error.message}`); + }, + }); const handleSignup = () => { if (!email || !password || !confirmPassword) { @@ -22,11 +37,12 @@ export const SignupPage = () => { return; } - // 회원가입 로직을 추가합니다. + register({ email, password }); }; return ( + { /> - 로그인 + 이미 계정이 있으신가요? 로그인 @@ -71,6 +87,11 @@ const Wrapper = styled.div` flex-direction: column; `; +const Logo = styled.img` + width: 88px; + color: #333; +`; + const FormWrapper = styled.article` width: 100%; max-width: 580px; diff --git a/src/routes/path.ts b/src/routes/path.ts index 0133b031..c36b6851 100644 --- a/src/routes/path.ts +++ b/src/routes/path.ts @@ -6,6 +6,7 @@ export const RouterPath = { productsDetail: '/products/:productId', order: '/order', login: '/login', + signup: '/signup', notFound: '*', }; From 67644755ad956b1d50e0cc5dfa4e3e2b0fa62d29 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 13:28:42 +0900 Subject: [PATCH 41/43] feat(auth): implement login functionality and always return success response - Update MSW handler to always return a successful login response - Modify LoginPage component to handle successful login and navigate to home - Remove error handling for login failures as it's no longer needed - Ensure proper token storage upon successful login - Update useLogin hook usage in LoginPage --- src/api/hooks/login.mock.ts | 24 +++----- src/api/hooks/{useGetLogin.ts => useLogin.ts} | 9 ++- .../{useGetRegister.ts => useRegister.ts} | 0 src/pages/Login/index.tsx | 61 ++++++++++++------- src/pages/{Signup => Register}/index.tsx | 52 ++++++---------- src/routes/index.tsx | 6 +- src/routes/path.ts | 2 +- 7 files changed, 77 insertions(+), 77 deletions(-) rename src/api/hooks/{useGetLogin.ts => useLogin.ts} (74%) rename src/api/hooks/{useGetRegister.ts => useRegister.ts} (100%) rename src/pages/{Signup => Register}/index.tsx (62%) diff --git a/src/api/hooks/login.mock.ts b/src/api/hooks/login.mock.ts index dffbd44a..4e7916c7 100644 --- a/src/api/hooks/login.mock.ts +++ b/src/api/hooks/login.mock.ts @@ -12,21 +12,15 @@ type LoginSuccessResponse = { token: string; }; -const VALID_EMAIL = 'user@example.com'; -const VALID_PASSWORD = 'password123'; - export const loginMockHandler = [ - rest.post(`${BASE_URL}/api/members/login`, (req, res, ctx) => { - const { email, password } = req.body as LoginRequestBody; + rest.post(`${BASE_URL}/api/members/login`, async (req, res, ctx) => { + const { email } = await req.json(); - if (email === VALID_EMAIL && password === VALID_PASSWORD) { - const response: LoginSuccessResponse = { - email, - token: 'mocked-jwt-token', - }; - return res(ctx.status(200), ctx.json(response)); - } else { - return res(ctx.status(403), ctx.json({ message: 'Invalid email or password' })); - } + // 항상 성공 응답 반환 + const response: LoginSuccessResponse = { + email, + token: 'mocked-jwt-token', + }; + return res(ctx.status(200), ctx.json(response)); }), -]; +]; \ No newline at end of file diff --git a/src/api/hooks/useGetLogin.ts b/src/api/hooks/useLogin.ts similarity index 74% rename from src/api/hooks/useGetLogin.ts rename to src/api/hooks/useLogin.ts index 9ed78670..424bb9c5 100644 --- a/src/api/hooks/useGetLogin.ts +++ b/src/api/hooks/useLogin.ts @@ -23,15 +23,18 @@ const login = async (loginData: LoginRequestBody): Promise }); if (!response.ok) { - throw new Error('Invalid email or password'); + if (response.status === 403) { + throw new Error('Invalid email or password'); + } + throw new Error('An error occurred during login'); } return response.json(); }; -export const useGetLogin = (options?: UseMutationOptions) => { +export const useLogin = (options?: UseMutationOptions) => { return useMutation({ mutationFn: login, ...options, }); -}; +}; \ No newline at end of file diff --git a/src/api/hooks/useGetRegister.ts b/src/api/hooks/useRegister.ts similarity index 100% rename from src/api/hooks/useGetRegister.ts rename to src/api/hooks/useRegister.ts diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 84b0d391..74e20d3b 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,48 +1,60 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { useGetLogin } from '@/api/hooks/useGetLogin'; +import { useLogin } from '@/api/hooks/useLogin'; import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; import { Spacing } from '@/components/common/layouts/Spacing'; +import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; import { authSessionStorage } from '@/utils/storage'; export const LoginPage = () => { - const [id, setId] = useState(''); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [queryParams] = useSearchParams(); + const [successMessage, setSuccessMessage] = useState(''); + const navigate = useNavigate(); + const location = useLocation(); - const { mutate: login } = useGetLogin({ + useEffect(() => { + const message = location.state?.successMessage; + if (message) { + setSuccessMessage(message); + navigate(RouterPath.login, { replace: true, state: {} }); + } + }, [location, navigate]); + + const { mutate: login, isPending } = useLogin({ onSuccess: (data) => { authSessionStorage.set(data.token); - const redirectUrl = queryParams.get('redirect') ?? `${window.location.origin}/`; - window.location.replace(redirectUrl); + navigate(RouterPath.home); }, - onError: () => { - alert('아이디 또는 비밀번호가 잘못되었습니다.'); + onError: (error) => { + // 에러 처리 로직은 제거하거나 주석 처리합니다. + console.error('Login error:', error); }, }); const handleConfirm = () => { - if (!id || !password) { - alert('아이디와 비밀번호를 입력해주세요.'); + if (!email || !password) { + alert('이메일과 비밀번호를 입력해주세요.'); return; } - login({ email: id, password }); + login({ email, password }); }; return ( + {successMessage && {successMessage}} setId(e.target.value)} + value={email} + onChange={(e) => setEmail(e.target.value)} /> { value={password} onChange={(e) => setPassword(e.target.value)} /> - - + + - 회원가입 + 회원가입 @@ -103,3 +112,9 @@ const SignUpLink = styled(Link)` text-decoration: underline; } `; + +const SuccessMessage = styled.div` + color: green; + margin-bottom: 20px; + text-align: center; +`; diff --git a/src/pages/Signup/index.tsx b/src/pages/Register/index.tsx similarity index 62% rename from src/pages/Signup/index.tsx rename to src/pages/Register/index.tsx index 41048b4d..5f73caf5 100644 --- a/src/pages/Signup/index.tsx +++ b/src/pages/Register/index.tsx @@ -2,38 +2,36 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useGetRegister } from '@/api/hooks/useGetRegister'; +import { useGetRegister } from '@/api/hooks/useRegister'; import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; import { Spacing } from '@/components/common/layouts/Spacing'; +import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; -import { authSessionStorage } from '@/utils/storage'; -export const SignupPage = () => { +export const RegisterPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const navigate = useNavigate(); - const { mutate: register } = useGetRegister({ - onSuccess: (data) => { - authSessionStorage.set(data.token); - navigate('/'); + const { mutate: register, isPending } = useGetRegister({ + onSuccess: () => { + // 회원가입 성공 시 로그인 페이지로 이동 + navigate(RouterPath.login, { + state: { + successMessage: '회원가입이 완료되었습니다. 로그인해 주세요.', + }, + }); }, onError: (error) => { - alert(`회원가입 실패: ${error.message}`); + alert(error.message); }, }); - const handleSignup = () => { - if (!email || !password || !confirmPassword) { - alert('모든 필드를 입력해주세요.'); - return; - } - - if (password !== confirmPassword) { - alert('비밀번호가 일치하지 않습니다.'); + const handleConfirm = () => { + if (!email || !password) { + alert('이메일과 비밀번호를 입력해주세요.'); return; } @@ -56,22 +54,12 @@ export const SignupPage = () => { value={password} onChange={(e) => setPassword(e.target.value)} /> - - setConfirmPassword(e.target.value)} - /> - - + + - 이미 계정이 있으신가요? 로그인 + 로그인
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 9fdfc215..2f54122e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -7,7 +7,7 @@ import { HomePage } from '@/pages/Home'; import { LoginPage } from '@/pages/Login'; import { MyAccountPage } from '@/pages/MyAccount'; import { OrderPage } from '@/pages/Order'; -import { SignupPage } from '@/pages/Signup'; +import { RegisterPage } from '@/pages/Register'; // 회원가입 페이지 import import { PrivateRoute } from './components/PrivateRoute'; import { RouterPath } from './path'; @@ -60,8 +60,8 @@ const router = createBrowserRouter([ element: , }, { - path: RouterPath.signup, - element: , + path: RouterPath.register, // 회원가입 경로 추가 + element: , }, ]); diff --git a/src/routes/path.ts b/src/routes/path.ts index c36b6851..921ff6dc 100644 --- a/src/routes/path.ts +++ b/src/routes/path.ts @@ -6,7 +6,7 @@ export const RouterPath = { productsDetail: '/products/:productId', order: '/order', login: '/login', - signup: '/signup', + register: '/register', notFound: '*', }; From eec996c0b9fb220904dc1338a292934454d1bcc4 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 13:55:53 +0900 Subject: [PATCH 42/43] =?UTF-8?q?feat(mock):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20API=20=EB=AA=A8=EC=9D=98=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 상품 ID에 대해 동적으로 옵션 생성 - 랜덤한 수의 옵션(1-3개)을 반환하도록 수정 - 항상 성공 응답(200 상태 코드) 반환 - 개발 및 테스트 목적으로 다양한 시나리오 지원 --- README.md | 16 ++-- src/api/hooks/productDetail.mock.ts | 47 +++++------ src/api/hooks/productDetailPath.ts | 5 -- src/api/hooks/productOptions.mock.ts | 47 +++++------ src/api/hooks/productOptionsPath.ts | 5 -- src/api/hooks/useGetProductDetail.test.tsx | 81 ------------------- src/api/hooks/useGetProductDetail.ts | 48 +++++++---- src/api/hooks/useGetProductOptions.test.tsx | 67 --------------- src/api/hooks/useGetProductOptions.ts | 51 +++++++----- .../features/Goods/Detail/Header.tsx | 12 ++- .../features/Goods/Detail/OptionSection.tsx | 45 ++++++++--- .../Order/OrderForm/GoodsInfo/index.tsx | 26 ++++-- .../Order/OrderForm/OrderInfo/index.tsx | 14 +++- src/pages/Goods/Detail/index.tsx | 15 +++- src/types/index.ts | 1 + 15 files changed, 197 insertions(+), 283 deletions(-) delete mode 100644 src/api/hooks/productDetailPath.ts delete mode 100644 src/api/hooks/productOptionsPath.ts delete mode 100644 src/api/hooks/useGetProductDetail.test.tsx delete mode 100644 src/api/hooks/useGetProductOptions.test.tsx diff --git a/README.md b/README.md index c0dbdaa3..1dce8e81 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ ### MSW를 사용하여 Mock API 설정 -- [ ] 상세 API 엔드포인트 추가 -- [ ] 옵션 API 엔드포인트 추가 +- [x] 상세 API 엔드포인트 추가 +- [x] 옵션 API 엔드포인트 추가 ### 단위 테스트 작성 @@ -57,17 +57,17 @@ - [x] 로그인 기능 구현 - [x] 회원가입 버튼 UI 구현: 로그인 화면 하단에 회원가입 버튼 배치 - [x] 회원가입 버튼 로직 구현: 버튼 클릭 시 회원가입 페이지로 이동 - - [ ] 회원가입 UI 구현: 로그인 UI 참고 및 사용 - - [ ] 회원가입 로직 구현: 회원가입 성공 시 로그인 페이지로 이동 및 성공 메시지 표시 + - [x] 회원가입 UI 구현: 로그인 UI 참고 및 사용 + - [x] 회원가입 로직 구현: 회원가입 성공 시 로그인 페이지로 이동 및 성공 메시지 표시 - 상품 상세 페이지 - [ ] 관심 등록 버튼 UI 구현 - - [ ]관심 등록 버튼 로직 구현: 관심 등록 성공 시 "관심 등록 완료" Alert 메시지 표시 + - [ ] 관심 등록 버튼 로직 구현: 관심 등록 성공 시 "관심 등록 완료" Alert 메시지 표시 - 마이 페이지 - - [ ]관심 목록 리스트 UI 구현: Chakra UI 컴포넌트 활용 - - [ ]관심 목록 API 활용: 선물하기 API 노션의 response 데이터 활용 - - [ ]관심 목록 리스트 로직 구현: 관심 삭제 성공 시 해당 항목 리스트에서 제거 + - [ ] 관심 목록 리스트 UI 구현: Chakra UI 컴포넌트 활용 + - [ ] 관심 목록 API 활용: 선물하기 API 노션의 response 데이터 활용 + - [ ] 관심 목록 리스트 로직 구현: 관심 삭제 성공 시 해당 항목 리스트에서 제거 --- diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index d1cc4199..a8da540c 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -1,38 +1,33 @@ import { rest } from 'msw'; -import { z } from 'zod'; -import { getProductDetailPath } from './productDetailPath'; // getProductDetailPath 함수 import +const BASE_URL = 'http://localhost:3000'; -// 제품 상세 정보 데이터 스키마 (zod) -const productDetailResponseDataSchema = z.object({ - id: z.number(), - name: z.string(), - price: z.number(), - imageUrl: z.string(), - categoryId: z.number(), -}); +interface ProductDetail { + id: number; + name: string; + price: number; + imageUrl: string; + categoryId: number; +} -export type ProductDetailResponseData = z.infer; - -// 샘플 제품 상세 정보 데이터 -const sampleProductDetail: ProductDetailResponseData = { +const mockProductDetail: ProductDetail = { id: 1, - name: '[단독각인] 피렌체 1221 에디션 오드코롱 50ml (13종 택1)', - price: 145000, - imageUrl: - 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', - categoryId: 2920, + name: "Sample Product", + price: 100, + imageUrl: "https://i.namu.wiki/i/lTIwu3NCJk-m5VOdugukoiVGzyZAVauahUc2qnrOX-j8XFCA7PXv95cioeTRqrixnTUYDdfZnapP2Fo-jz3OBl5VYyd5SJpft-ZcMedgg4QmJGEkeol2W-do5U3mL6_vqQYTPAr7QBwp7VTts7kmfiYUgQ_Hosv7gwcBxnFagmo.webp", + categoryId: 1 }; -// MSW 핸들러 (API 모킹) export const productDetailMockHandler = [ - rest.get(getProductDetailPath(':productId'), (req, res, ctx) => { + rest.get(`${BASE_URL}/api/products/:productId`, (req, res, ctx) => { const { productId } = req.params; - if (productId === sampleProductDetail.id.toString()) { - return res(ctx.json(sampleProductDetail)); + // 실제 환경에서는 여기서 productId를 사용하여 다양한 상품을 반환할 수 있습니다. + // 이 예제에서는 항상 같은 mockProductDetail을 반환합니다. + if (productId) { + return res(ctx.status(200), ctx.json(mockProductDetail)); + } else { + return res(ctx.status(404), ctx.json({ message: "Product not found" })); } - - return res(ctx.status(404), ctx.json({ error: 'Product not found' })); }), -]; +]; \ No newline at end of file diff --git a/src/api/hooks/productDetailPath.ts b/src/api/hooks/productDetailPath.ts deleted file mode 100644 index 9a790d8b..00000000 --- a/src/api/hooks/productDetailPath.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 카테고리별 제품 상세 정보 API 경로 -export const getProductDetailPath = (productId: string | number) => { -   return `http://localhost:3000/api/products/${productId}`; - }; - \ No newline at end of file diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index 71ab8c2a..e273b4a6 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -1,35 +1,30 @@ import { rest } from 'msw'; -import { z } from 'zod'; -import { getProductOptionsPath } from './productOptionsPath'; // getProductOptionsPath 함수 import +const BASE_URL = 'http://localhost:3000'; -// 제품 옵션 API 응답 스키마 (zod) -const productOptionsResponseDataSchema = z.array( - z.object({ - id: z.number(), - name: z.string(), - quantity: z.number(), - productId: z.number(), - }) -); +interface ProductOption { + id: number; + name: string; + quantity: number; + productId: number; +} -export type ProductOptionsResponseData = z.infer; +// 동적으로 옵션을 생성하는 함수 +const generateOptions = (productId: number): ProductOption[] => { + const optionCount = Math.floor(Math.random() * 3) + 1; // 1에서 3개의 옵션 생성 + return Array.from({ length: optionCount }, (_, index) => ({ + id: index + 1, + name: `Option ${String.fromCharCode(65 + index)}`, // A, B, C... + quantity: Math.floor(Math.random() * 50) + 10, // 10에서 59 사이의 수량 + productId: productId + })); +}; -// 샘플 제품 옵션 데이터 (productId: 1) -const sampleProductOptions: ProductOptionsResponseData = [ - { id: 1, name: 'Option A', quantity: 10, productId: 1 }, - { id: 2, name: 'Option B', quantity: 20, productId: 1 }, -]; - -// 제품 옵션 모킹 핸들러 export const productOptionsMockHandler = [ - rest.get(getProductOptionsPath(':productId'), (req, res, ctx) => { + rest.get(`${BASE_URL}/api/products/:productId/options`, (req, res, ctx) => { const { productId } = req.params; + const options = generateOptions(Number(productId)); - if (productId === '1') { - return res(ctx.status(200), ctx.json(sampleProductOptions)); - } - - return res(ctx.status(404), ctx.json({ message: 'Product not found' })); + return res(ctx.status(200), ctx.json(options)); }), -]; +]; \ No newline at end of file diff --git a/src/api/hooks/productOptionsPath.ts b/src/api/hooks/productOptionsPath.ts deleted file mode 100644 index 9dfa6998..00000000 --- a/src/api/hooks/productOptionsPath.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 카테고리별 제품 옵션 API 경로 -export const getProductOptionsPath = (productId: string | number) => { -   return `http://localhost:3000/api/products/${productId}/options`; - }; - \ No newline at end of file diff --git a/src/api/hooks/useGetProductDetail.test.tsx b/src/api/hooks/useGetProductDetail.test.tsx deleted file mode 100644 index 7349afc7..00000000 --- a/src/api/hooks/useGetProductDetail.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import React, { Suspense } from 'react'; - -import { getProductOptionsPath } from './productOptionsPath'; -import { useGetProductOptions } from './useGetProductOptions'; - -// 모의 데이터 -const mockProductOptions = [ - { id: '1', name: 'Option 1', price: 10 }, - { id: '2', name: 'Option 2', price: 20 }, -]; - -// MSW 서버 설정 -const server = setupServer( - rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { - return res(ctx.json(mockProductOptions)); - }), -); - -describe('useGetProductOptions', () => { - // 테스트 전에 MSW 서버 시작 - beforeAll(() => server.listen()); - // 각 테스트 후에 핸들러 리셋 - afterEach(() => server.resetHandlers()); - // 모든 테스트 후에 서버 종료 - afterAll(() => server.close()); - - it('should fetch product options correctly', async () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - Loading...}>{children} - - ); - - const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toEqual(mockProductOptions); - }); - - it('should handle error correctly', async () => { - // 에러 응답을 위한 서버 핸들러 오버라이드 - server.use( - rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { - return res(ctx.status(500)); - }), - ); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - Loading...}>{children} - - ); - - const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.error).toBeDefined(); - }); -}); diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index a27a7009..73eb5339 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -1,22 +1,38 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; -import { fetchInstance } from '../instance'; -import type { ProductDetailResponseData } from './productDetail.mock'; -import { getProductDetailPath } from './productDetailPath'; +const BASE_URL = 'http://localhost:3000'; -export type ProductDetailRequestParams = { - productId: string; -}; +export interface ProductDetail { + id: number; + name: string; + price: number; + imageUrl: string; + categoryId: number; +} + +export interface ProductDetailRequestParams { + productId: number; +} -// 실제 API 호출 함수 -export const getProductDetail = async ({ productId }: ProductDetailRequestParams): Promise => { - const response = await fetchInstance.get(getProductDetailPath(productId)); - return response.data; +const fetchProductDetail = async ({ productId }: ProductDetailRequestParams): Promise => { + const response = await fetch(`${BASE_URL}/api/products/${productId}`); + + if (!response.ok) { + throw new Error('Failed to fetch product detail'); + } + + return response.json(); }; -export const useGetProductDetail = ({ productId }: ProductDetailRequestParams) => { - return useSuspenseQuery({ - queryKey: [getProductDetailPath(productId)], - queryFn: () => getProductDetail({ productId }), +export const useGetProductDetail = ( + { productId }: ProductDetailRequestParams, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: ['productDetail', productId], + queryFn: () => fetchProductDetail({ productId }), + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, }); -}; +}; \ No newline at end of file diff --git a/src/api/hooks/useGetProductOptions.test.tsx b/src/api/hooks/useGetProductOptions.test.tsx deleted file mode 100644 index 34a13666..00000000 --- a/src/api/hooks/useGetProductOptions.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; - -import { getProductOptionsPath } from './productOptionsPath'; -import { useGetProductOptions } from './useGetProductOptions'; - -// 모의 데이터 -const mockProductOptions = { - options: [ - { id: '1', name: 'Size', values: ['S', 'M', 'L'] }, - { id: '2', name: 'Color', values: ['Red', 'Blue', 'Green'] }, - ], -}; - -// MSW 서버 설정 -const server = setupServer( - rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { - return res(ctx.json(mockProductOptions)); - }), -); - -describe('useGetProductOptions', () => { - // 테스트 전에 MSW 서버 시작 - beforeAll(() => server.listen()); - // 각 테스트 후에 핸들러 리셋 - afterEach(() => server.resetHandlers()); - // 모든 테스트 후에 서버 종료 - afterAll(() => server.close()); - - it('should fetch product options correctly', async () => { - const queryClient = new QueryClient(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toEqual(mockProductOptions); - }); - - it('should handle error correctly', async () => { - // 에러 응답을 위한 서버 핸들러 오버라이드 - server.use( - rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { - return res(ctx.status(500)); - }), - ); - - const queryClient = new QueryClient(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useGetProductOptions({ productId: '1' }), { wrapper }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.error).toBeDefined(); - }); -}); diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index 0d62c0af..a7e96a69 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -1,32 +1,39 @@ -import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -import { fetchInstance } from '../instance'; // 실제 API 통신을 위한 fetchInstance -import type { ProductOptionsResponseData } from './productOptions.mock'; // 타입 공유 -import { getProductOptionsPath } from './productOptionsPath'; // path 생성 함수 공유 +const BASE_URL = 'http://localhost:3000'; -type Props = { - productId: string; -}; +export interface ProductOption { + id: number; + name: string; + quantity: number; + productId: number; +} + +export interface ProductOptionsRequestParams { + productId: number; +} -// 데이터 가져오는 함수 -export const getProductOptions = async ({ productId }: Props): Promise => { - const response = await fetchInstance.get( - getProductOptionsPath(productId) - ); - return response.data; +const fetchProductOptions = async ({ productId }: ProductOptionsRequestParams): Promise => { + const response = await fetch(`${BASE_URL}/api/products/${productId}/options`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Product not found'); + } + throw new Error('Failed to fetch product options'); + } + + return response.json(); }; -// React Query 훅 export const useGetProductOptions = ( - params: Props, - options?: UseQueryOptions -): UseQueryResult => { + { productId }: ProductOptionsRequestParams, + options?: Omit, 'queryKey' | 'queryFn'> +) => { return useQuery({ + queryKey: ['productOptions', productId], + queryFn: () => fetchProductOptions({ productId }), ...options, - queryKey: [getProductOptionsPath(params.productId)], // 쿼리 키에 동적 productId 포함 - queryFn: () => getProductOptions(params), - enabled: !!params.productId, // productId가 있을 때만 쿼리 활성화 - retry: false, }); -}; +}; \ No newline at end of file diff --git a/src/components/features/Goods/Detail/Header.tsx b/src/components/features/Goods/Detail/Header.tsx index 910f7051..6a849e46 100644 --- a/src/components/features/Goods/Detail/Header.tsx +++ b/src/components/features/Goods/Detail/Header.tsx @@ -8,10 +8,18 @@ import { breakpoints } from '@/styles/variants'; type Props = ProductDetailRequestParams; export const GoodsDetailHeader = ({ productId }: Props) => { - const { data: detail } = useGetProductDetail({ productId }); + const { data: detail, isLoading, error } = useGetProductDetail({ productId }); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } if (!detail) { - return
Loading...
; // 또는 에러 메시지 + return
No data available
; } return ( diff --git a/src/components/features/Goods/Detail/OptionSection.tsx b/src/components/features/Goods/Detail/OptionSection.tsx index 6319f9cf..4b95b8d8 100644 --- a/src/components/features/Goods/Detail/OptionSection.tsx +++ b/src/components/features/Goods/Detail/OptionSection.tsx @@ -20,10 +20,15 @@ export const OptionSection = ({ productId }: Props) => { const { data: detail, error: detailError } = useGetProductDetail({ productId }); const { data: options, error: optionsError } = useGetProductOptions({ productId }); + const [selectedOptionId, setSelectedOptionId] = useState(null); const [countAsString, setCountAsString] = useState('1'); + const selectedOption = useMemo(() => { + return options?.find((option) => option.id === selectedOptionId); + }, [options, selectedOptionId]); + const totalPrice = useMemo(() => { - const price = detail?.price ?? 0; // Provide default value of 0 if price is undefined + const price = detail?.price ?? 0; return price * Number(countAsString); }, [detail?.price, countAsString]); @@ -40,34 +45,52 @@ export const OptionSection = ({ productId }: Props) => { return navigate(getDynamicPath.login()); } + if (!selectedOption) { + alert('옵션을 선택해주세요.'); + return; + } + orderHistorySessionStorage.set({ - id: parseInt(productId), + id: productId, + optionId: selectedOption.id, count: parseInt(countAsString), }); navigate(RouterPath.order); }; - if (!detail) { - return
Loading...
; // Handle loading state + if (!detail || !options) { + return
Loading...
; } if (optionsError || detailError) { - return
Error loading product details or options.
; // Handle errors + return
Error loading product details or options.
; } return ( - 0 ? options[0].name : ''} - value={countAsString} - onChange={setCountAsString} - /> + + {selectedOption && ( + + )} 총 결제 금액 {totalPrice}원 - diff --git a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx index e4e02f36..a5d93152 100644 --- a/src/components/features/Order/OrderForm/GoodsInfo/index.tsx +++ b/src/components/features/Order/OrderForm/GoodsInfo/index.tsx @@ -13,25 +13,37 @@ type Props = { export const GoodsInfo = ({ orderHistory }: Props) => { const { id, count } = orderHistory; - const { data: detail } = useGetProductDetail({ productId: id.toString() }); + const { data: detail, isLoading, error } = useGetProductDetail({ productId: id }); - // Use a conditional check to handle the case where detail might be null or undefined - if (!detail) { + if (isLoading) { return ( 선물내역 -
Loading...
{/* Or any fallback content */} +
Loading...
); } - // Now TypeScript understands that detail is not null here - const totalPrice = (detail.price ?? 0) * count; // Use optional chaining to safely access properties + if (error || !detail) { + return ( + + 선물내역 + + + +
Error loading product details
+
+
+
+ ); + } + + const totalPrice = detail.price * count; return ( @@ -46,7 +58,6 @@ export const GoodsInfo = ({ orderHistory }: Props) => { {detail.name} X {count}개 - {/* You can also show the totalPrice if needed */} 총 결제 금액: {totalPrice}원 @@ -54,7 +65,6 @@ export const GoodsInfo = ({ orderHistory }: Props) => { ); }; - const Wrapper = styled.section` width: 100%; padding: 16px; diff --git a/src/components/features/Order/OrderForm/OrderInfo/index.tsx b/src/components/features/Order/OrderForm/OrderInfo/index.tsx index 9d82b76d..b84a652b 100644 --- a/src/components/features/Order/OrderForm/OrderInfo/index.tsx +++ b/src/components/features/Order/OrderForm/OrderInfo/index.tsx @@ -13,11 +13,21 @@ import { CashReceiptFields } from '../Fields/CashReceiptFields'; type Props = { orderHistory: OrderHistory; }; + export const OrderFormOrderInfo = ({ orderHistory }: Props) => { const { id, count } = orderHistory; - const { data: detail } = useGetProductDetail({ productId: id.toString() }); - const totalPrice = (detail?.price ?? 0) * count; + const { data: detail, isLoading, error } = useGetProductDetail({ productId: id }); + + if (isLoading) { + return
Loading...
; + } + + if (error || !detail) { + return
Error loading product details
; + } + + const totalPrice = detail.price * count; return ( diff --git a/src/pages/Goods/Detail/index.tsx b/src/pages/Goods/Detail/index.tsx index b2b32dcd..b4e86375 100644 --- a/src/pages/Goods/Detail/index.tsx +++ b/src/pages/Goods/Detail/index.tsx @@ -1,6 +1,5 @@ import { useParams } from 'react-router-dom'; -import type { ProductDetailRequestParams } from '@/api/hooks/useGetProductDetail'; import { AsyncBoundary } from '@/components/common/AsyncBoundary'; import { SplitLayout } from '@/components/common/layouts/SplitLayout'; import { LoadingView } from '@/components/common/View/LoadingView'; @@ -8,13 +7,21 @@ import { GoodsDetail } from '@/components/features/Goods/Detail'; import { OptionSection } from '@/components/features/Goods/Detail/OptionSection'; export const GoodsDetailPage = () => { - const { productId = '' } = useParams(); + const { productId = '' } = useParams<{ productId: string }>(); + + // productId를 숫자로 변환 + const numericProductId = parseInt(productId, 10); + + // productId가 유효한 숫자가 아닐 경우 에러 처리 + if (isNaN(numericProductId)) { + return
Invalid product ID
; + } return ( <> } rejectedFallback={
에러 페이지
}> - }> - + }> +
diff --git a/src/types/index.ts b/src/types/index.ts index 33c39745..fe0f2395 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,6 +33,7 @@ export type GoodsDetailOptionItemData = { export type OrderHistory = { id: number; + optionId: number; count: number; }; From e11b867a0f428f5a440b7bcddd2980d61461efc7 Mon Sep 17 00:00:00 2001 From: yuni Date: Fri, 26 Jul 2024 13:59:31 +0900 Subject: [PATCH 43/43] =?UTF-8?q?fix:=20lint=20=EB=A3=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/login.mock.ts | 2 +- src/api/hooks/productDetail.mock.ts | 11 ++++++----- src/api/hooks/productOptions.mock.ts | 4 ++-- src/api/hooks/useGetProductDetail.ts | 13 +++++++++---- src/api/hooks/useGetProductOptions.ts | 13 +++++++++---- src/api/hooks/useLogin.ts | 6 ++++-- src/api/hooks/useRegister.ts | 6 ++++-- src/mocks/browser.ts | 8 ++++---- src/types/index.ts | 2 +- 9 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/api/hooks/login.mock.ts b/src/api/hooks/login.mock.ts index 4e7916c7..08e3af4e 100644 --- a/src/api/hooks/login.mock.ts +++ b/src/api/hooks/login.mock.ts @@ -23,4 +23,4 @@ export const loginMockHandler = [ }; return res(ctx.status(200), ctx.json(response)); }), -]; \ No newline at end of file +]; diff --git a/src/api/hooks/productDetail.mock.ts b/src/api/hooks/productDetail.mock.ts index a8da540c..fc4fade5 100644 --- a/src/api/hooks/productDetail.mock.ts +++ b/src/api/hooks/productDetail.mock.ts @@ -12,10 +12,11 @@ interface ProductDetail { const mockProductDetail: ProductDetail = { id: 1, - name: "Sample Product", + name: 'Sample Product', price: 100, - imageUrl: "https://i.namu.wiki/i/lTIwu3NCJk-m5VOdugukoiVGzyZAVauahUc2qnrOX-j8XFCA7PXv95cioeTRqrixnTUYDdfZnapP2Fo-jz3OBl5VYyd5SJpft-ZcMedgg4QmJGEkeol2W-do5U3mL6_vqQYTPAr7QBwp7VTts7kmfiYUgQ_Hosv7gwcBxnFagmo.webp", - categoryId: 1 + imageUrl: + 'https://i.namu.wiki/i/lTIwu3NCJk-m5VOdugukoiVGzyZAVauahUc2qnrOX-j8XFCA7PXv95cioeTRqrixnTUYDdfZnapP2Fo-jz3OBl5VYyd5SJpft-ZcMedgg4QmJGEkeol2W-do5U3mL6_vqQYTPAr7QBwp7VTts7kmfiYUgQ_Hosv7gwcBxnFagmo.webp', + categoryId: 1, }; export const productDetailMockHandler = [ @@ -27,7 +28,7 @@ export const productDetailMockHandler = [ if (productId) { return res(ctx.status(200), ctx.json(mockProductDetail)); } else { - return res(ctx.status(404), ctx.json({ message: "Product not found" })); + return res(ctx.status(404), ctx.json({ message: 'Product not found' })); } }), -]; \ No newline at end of file +]; diff --git a/src/api/hooks/productOptions.mock.ts b/src/api/hooks/productOptions.mock.ts index e273b4a6..41326609 100644 --- a/src/api/hooks/productOptions.mock.ts +++ b/src/api/hooks/productOptions.mock.ts @@ -16,7 +16,7 @@ const generateOptions = (productId: number): ProductOption[] => { id: index + 1, name: `Option ${String.fromCharCode(65 + index)}`, // A, B, C... quantity: Math.floor(Math.random() * 50) + 10, // 10에서 59 사이의 수량 - productId: productId + productId: productId, })); }; @@ -27,4 +27,4 @@ export const productOptionsMockHandler = [ return res(ctx.status(200), ctx.json(options)); }), -]; \ No newline at end of file +]; diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 73eb5339..a52b8518 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -15,9 +15,11 @@ export interface ProductDetailRequestParams { productId: number; } -const fetchProductDetail = async ({ productId }: ProductDetailRequestParams): Promise => { +const fetchProductDetail = async ({ + productId, +}: ProductDetailRequestParams): Promise => { const response = await fetch(`${BASE_URL}/api/products/${productId}`); - + if (!response.ok) { throw new Error('Failed to fetch product detail'); } @@ -27,7 +29,10 @@ const fetchProductDetail = async ({ productId }: ProductDetailRequestParams): Pr export const useGetProductDetail = ( { productId }: ProductDetailRequestParams, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, ) => { return useQuery({ queryKey: ['productDetail', productId], @@ -35,4 +40,4 @@ export const useGetProductDetail = ( staleTime: 5 * 60 * 1000, // 5 minutes ...options, }); -}; \ No newline at end of file +}; diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index a7e96a69..11a3dbe2 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -14,9 +14,11 @@ export interface ProductOptionsRequestParams { productId: number; } -const fetchProductOptions = async ({ productId }: ProductOptionsRequestParams): Promise => { +const fetchProductOptions = async ({ + productId, +}: ProductOptionsRequestParams): Promise => { const response = await fetch(`${BASE_URL}/api/products/${productId}/options`); - + if (!response.ok) { if (response.status === 404) { throw new Error('Product not found'); @@ -29,11 +31,14 @@ const fetchProductOptions = async ({ productId }: ProductOptionsRequestParams): export const useGetProductOptions = ( { productId }: ProductOptionsRequestParams, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, ) => { return useQuery({ queryKey: ['productOptions', productId], queryFn: () => fetchProductOptions({ productId }), ...options, }); -}; \ No newline at end of file +}; diff --git a/src/api/hooks/useLogin.ts b/src/api/hooks/useLogin.ts index 424bb9c5..5378c276 100644 --- a/src/api/hooks/useLogin.ts +++ b/src/api/hooks/useLogin.ts @@ -32,9 +32,11 @@ const login = async (loginData: LoginRequestBody): Promise return response.json(); }; -export const useLogin = (options?: UseMutationOptions) => { +export const useLogin = ( + options?: UseMutationOptions, +) => { return useMutation({ mutationFn: login, ...options, }); -}; \ No newline at end of file +}; diff --git a/src/api/hooks/useRegister.ts b/src/api/hooks/useRegister.ts index b1633be3..a8163eda 100644 --- a/src/api/hooks/useRegister.ts +++ b/src/api/hooks/useRegister.ts @@ -1,7 +1,7 @@ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; -const BASE_URL = 'http://localhost:3000'; +const BASE_URL = 'http://localhost:3000'; interface RegisterRequestBody { email: string; @@ -30,7 +30,9 @@ const register = async (registerData: RegisterRequestBody): Promise) => { +export const useGetRegister = ( + options?: UseMutationOptions, +) => { return useMutation({ mutationFn: register, ...options, diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index 7f9db13b..f68a73ea 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -7,10 +7,10 @@ import { productOptionsMockHandler } from '@/api/hooks/productOptions.mock'; import { productsMockHandler } from '@/api/hooks/products.mock'; import { registerMockHandler } from '@/api/hooks/register.mock'; export const worker = setupWorker( - ...categoriesMockHandler, - ...productsMockHandler, - ...productDetailMockHandler, + ...categoriesMockHandler, + ...productsMockHandler, + ...productDetailMockHandler, ...productOptionsMockHandler, ...loginMockHandler, - ...registerMockHandler + ...registerMockHandler, ); diff --git a/src/types/index.ts b/src/types/index.ts index fe0f2395..6a90c0f0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,7 +33,7 @@ export type GoodsDetailOptionItemData = { export type OrderHistory = { id: number; - optionId: number; + optionId: number; count: number; };