feat: React 성능 최적화 및 Vercel 배포 시스템 구축 완료
🚀 성능 최적화 (Task 8): - React.lazy를 활용한 코드 스플리팅 구현 - React.memo, useMemo, useCallback을 통한 메모이제이션 최적화 - 초기 번들 크기 87% 감소 (470kB → 62kB) - 백그라운드 동기화 간격 최적화 (5분 → 30초) 📦 Vercel 배포 인프라 구축 (Task 9): - vercel.json 배포 설정 및 보안 헤더 구성 - GitHub Actions 자동 배포 워크플로우 설정 - 환경별 배포 및 미리보기 시스템 구현 - 자동화된 배포 스크립트 및 환경 변수 관리 - 포괄적인 배포 가이드 및 체크리스트 작성 🔧 코드 품질 개선: - ESLint 주요 오류 수정 (사용하지 않는 변수/import 정리) - 테스트 커버리지 확장 (229개 테스트 통과) - TypeScript 타입 안전성 강화 - Prettier 코드 포맷팅 적용 ⚠️ 참고: 테스트 파일의 any 타입 및 일부 경고는 향후 개선 예정 🛠️ Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,50 +1,60 @@
|
|||||||
## 📋 변경 사항
|
## 📋 변경 사항
|
||||||
|
|
||||||
### 🔧 변경 내용
|
### 🔧 변경 내용
|
||||||
|
|
||||||
<!-- 이번 PR에서 수정한 내용을 간략하게 설명해주세요 -->
|
<!-- 이번 PR에서 수정한 내용을 간략하게 설명해주세요 -->
|
||||||
|
|
||||||
### 🎯 변경 이유
|
### 🎯 변경 이유
|
||||||
|
|
||||||
<!-- 왜 이 변경이 필요한지 설명해주세요 -->
|
<!-- 왜 이 변경이 필요한지 설명해주세요 -->
|
||||||
|
|
||||||
### 📸 스크린샷 (있는 경우)
|
### 📸 스크린샷 (있는 경우)
|
||||||
|
|
||||||
<!-- UI 변경이 있다면 스크린샷을 첨부해주세요 -->
|
<!-- UI 변경이 있다면 스크린샷을 첨부해주세요 -->
|
||||||
|
|
||||||
## ✅ 체크리스트
|
## ✅ 체크리스트
|
||||||
|
|
||||||
### 코드 품질
|
### 코드 품질
|
||||||
|
|
||||||
- [ ] 모든 테스트가 통과함 (`npm run test:run`)
|
- [ ] 모든 테스트가 통과함 (`npm run test:run`)
|
||||||
- [ ] 타입 검사가 통과함 (`npm run type-check`)
|
- [ ] 타입 검사가 통과함 (`npm run type-check`)
|
||||||
- [ ] 린트 검사가 통과함 (`npm run lint`)
|
- [ ] 린트 검사가 통과함 (`npm run lint`)
|
||||||
- [ ] 프로덕션 빌드가 성공함 (`npm run build`)
|
- [ ] 프로덕션 빌드가 성공함 (`npm run build`)
|
||||||
|
|
||||||
### 기능 테스트
|
### 기능 테스트
|
||||||
|
|
||||||
- [ ] 새로운 기능이 예상대로 동작함
|
- [ ] 새로운 기능이 예상대로 동작함
|
||||||
- [ ] 기존 기능에 영향을 주지 않음
|
- [ ] 기존 기능에 영향을 주지 않음
|
||||||
- [ ] 모바일에서 정상 동작함
|
- [ ] 모바일에서 정상 동작함
|
||||||
- [ ] 다크모드/라이트모드에서 정상 동작함
|
- [ ] 다크모드/라이트모드에서 정상 동작함
|
||||||
|
|
||||||
### 성능 및 보안
|
### 성능 및 보안
|
||||||
|
|
||||||
- [ ] 새로운 의존성 추가 시 보안 검토 완료
|
- [ ] 새로운 의존성 추가 시 보안 검토 완료
|
||||||
- [ ] 성능에 부정적인 영향이 없음
|
- [ ] 성능에 부정적인 영향이 없음
|
||||||
- [ ] 번들 크기가 크게 증가하지 않음
|
- [ ] 번들 크기가 크게 증가하지 않음
|
||||||
|
|
||||||
### 문서화
|
### 문서화
|
||||||
|
|
||||||
- [ ] 필요한 경우 문서 업데이트 완료
|
- [ ] 필요한 경우 문서 업데이트 완료
|
||||||
- [ ] 새로운 환경 변수 추가 시 .env.example 업데이트
|
- [ ] 새로운 환경 변수 추가 시 .env.example 업데이트
|
||||||
|
|
||||||
## 🚀 배포 확인
|
## 🚀 배포 확인
|
||||||
|
|
||||||
### Vercel 미리보기
|
### Vercel 미리보기
|
||||||
|
|
||||||
- [ ] Vercel 배포가 성공함
|
- [ ] Vercel 배포가 성공함
|
||||||
- [ ] 미리보기 URL에서 정상 동작 확인
|
- [ ] 미리보기 URL에서 정상 동작 확인
|
||||||
- [ ] 프로덕션 환경과 동일하게 동작함
|
- [ ] 프로덕션 환경과 동일하게 동작함
|
||||||
|
|
||||||
### 추가 정보
|
### 추가 정보
|
||||||
|
|
||||||
<!-- 리뷰어가 알아야 할 추가 정보나 주의사항이 있다면 작성해주세요 -->
|
<!-- 리뷰어가 알아야 할 추가 정보나 주의사항이 있다면 작성해주세요 -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**📝 참고사항:**
|
**📝 참고사항:**
|
||||||
|
|
||||||
- 이 PR이 병합되면 자동으로 프로덕션에 배포됩니다.
|
- 이 PR이 병합되면 자동으로 프로덕션에 배포됩니다.
|
||||||
- Vercel 미리보기 링크는 이 PR에 자동으로 코멘트됩니다.
|
- Vercel 미리보기 링크는 이 PR에 자동으로 코멘트됩니다.
|
||||||
- 배포 상태는 GitHub Actions에서 확인할 수 있습니다.
|
- 배포 상태는 GitHub Actions에서 확인할 수 있습니다.
|
||||||
8
.github/workflows/deployment-monitor.yml
vendored
8
.github/workflows/deployment-monitor.yml
vendored
@@ -17,8 +17,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "18"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -86,8 +86,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "18"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
8
.github/workflows/vercel-deployment.yml
vendored
8
.github/workflows/vercel-deployment.yml
vendored
@@ -17,8 +17,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "18"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -73,8 +73,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "18"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -7,6 +7,7 @@ Zellyy Finance는 React와 TypeScript로 구축된 개인 가계부 관리 애
|
|||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
|
|
||||||
- **React 18** - 메인 UI 프레임워크
|
- **React 18** - 메인 UI 프레임워크
|
||||||
- **TypeScript** - 타입 안전성 보장
|
- **TypeScript** - 타입 안전성 보장
|
||||||
- **Vite** - 빠른 개발 서버 및 빌드 도구
|
- **Vite** - 빠른 개발 서버 및 빌드 도구
|
||||||
@@ -16,15 +17,18 @@ Zellyy Finance는 React와 TypeScript로 구축된 개인 가계부 관리 애
|
|||||||
- **Zustand** - 상태 관리
|
- **Zustand** - 상태 관리
|
||||||
|
|
||||||
### 백엔드 및 인증
|
### 백엔드 및 인증
|
||||||
|
|
||||||
- **Appwrite** - 백엔드 서비스 (인증, 데이터베이스)
|
- **Appwrite** - 백엔드 서비스 (인증, 데이터베이스)
|
||||||
- **React Hook Form** - 폼 상태 관리 및 유효성 검사
|
- **React Hook Form** - 폼 상태 관리 및 유효성 검사
|
||||||
|
|
||||||
### 테스팅
|
### 테스팅
|
||||||
|
|
||||||
- **Vitest** - 테스트 러너
|
- **Vitest** - 테스트 러너
|
||||||
- **React Testing Library** - 컴포넌트 테스팅
|
- **React Testing Library** - 컴포넌트 테스팅
|
||||||
- **@testing-library/jest-dom** - DOM 테스팅 유틸리티
|
- **@testing-library/jest-dom** - DOM 테스팅 유틸리티
|
||||||
|
|
||||||
### 개발 도구
|
### 개발 도구
|
||||||
|
|
||||||
- **ESLint** - 코드 품질 검사
|
- **ESLint** - 코드 품질 검사
|
||||||
- **Prettier** - 코드 포맷팅
|
- **Prettier** - 코드 포맷팅
|
||||||
- **Task Master AI** - 프로젝트 관리 및 작업 추적
|
- **Task Master AI** - 프로젝트 관리 및 작업 추적
|
||||||
@@ -62,30 +66,35 @@ src/
|
|||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
### 1. 사용자 인증
|
### 1. 사용자 인증
|
||||||
|
|
||||||
- 이메일/비밀번호 기반 로그인
|
- 이메일/비밀번호 기반 로그인
|
||||||
- 회원가입 및 계정 관리
|
- 회원가입 및 계정 관리
|
||||||
- 비밀번호 재설정
|
- 비밀번호 재설정
|
||||||
- 세션 관리
|
- 세션 관리
|
||||||
|
|
||||||
### 2. 거래 관리
|
### 2. 거래 관리
|
||||||
|
|
||||||
- 수입/지출 등록 및 편집
|
- 수입/지출 등록 및 편집
|
||||||
- 카테고리별 분류
|
- 카테고리별 분류
|
||||||
- 결제 수단 관리
|
- 결제 수단 관리
|
||||||
- 거래 내역 검색 및 필터링
|
- 거래 내역 검색 및 필터링
|
||||||
|
|
||||||
### 3. 예산 관리
|
### 3. 예산 관리
|
||||||
|
|
||||||
- 월간/주간/일간 예산 설정
|
- 월간/주간/일간 예산 설정
|
||||||
- 카테고리별 예산 분배
|
- 카테고리별 예산 분배
|
||||||
- 예산 대비 지출 현황 시각화
|
- 예산 대비 지출 현황 시각화
|
||||||
- 예산 초과 알림
|
- 예산 초과 알림
|
||||||
|
|
||||||
### 4. 분석 및 통계
|
### 4. 분석 및 통계
|
||||||
|
|
||||||
- 카테고리별 지출 분석
|
- 카테고리별 지출 분석
|
||||||
- 결제 수단별 통계
|
- 결제 수단별 통계
|
||||||
- 월간/연간 트렌드 분석
|
- 월간/연간 트렌드 분석
|
||||||
- 차트 및 그래프 시각화
|
- 차트 및 그래프 시각화
|
||||||
|
|
||||||
### 5. 오프라인 모드
|
### 5. 오프라인 모드
|
||||||
|
|
||||||
- 네트워크 상태 감지
|
- 네트워크 상태 감지
|
||||||
- 오프라인 데이터 로컬 저장
|
- 오프라인 데이터 로컬 저장
|
||||||
- 온라인 복구 시 자동 동기화
|
- 온라인 복구 시 자동 동기화
|
||||||
@@ -93,6 +102,7 @@ src/
|
|||||||
## 개발 명령어
|
## 개발 명령어
|
||||||
|
|
||||||
### 기본 명령어
|
### 기본 명령어
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # 개발 서버 시작
|
npm run dev # 개발 서버 시작
|
||||||
npm run build # 프로덕션 빌드
|
npm run build # 프로덕션 빌드
|
||||||
@@ -105,6 +115,7 @@ npm run test:coverage # 테스트 커버리지 확인
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Task Master 명령어
|
### Task Master 명령어
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
task-master next # 다음 작업 확인
|
task-master next # 다음 작업 확인
|
||||||
task-master list # 모든 작업 목록
|
task-master list # 모든 작업 목록
|
||||||
@@ -115,24 +126,28 @@ task-master set-status --id=<id> --status=done # 작업 완료 표시
|
|||||||
## 코딩 컨벤션
|
## 코딩 컨벤션
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- 모든 파일에 엄격한 타입 정의 사용
|
- 모든 파일에 엄격한 타입 정의 사용
|
||||||
- `any` 타입 사용 금지
|
- `any` 타입 사용 금지
|
||||||
- 인터페이스와 타입 별칭 적절히 활용
|
- 인터페이스와 타입 별칭 적절히 활용
|
||||||
- Enum보다 const assertion 선호
|
- Enum보다 const assertion 선호
|
||||||
|
|
||||||
### React 컴포넌트
|
### React 컴포넌트
|
||||||
|
|
||||||
- 함수형 컴포넌트 사용
|
- 함수형 컴포넌트 사용
|
||||||
- Props 인터페이스 명시적 정의
|
- Props 인터페이스 명시적 정의
|
||||||
- 커스텀 훅으로 로직 분리
|
- 커스텀 훅으로 로직 분리
|
||||||
- `React.FC` 타입 명시적 사용
|
- `React.FC` 타입 명시적 사용
|
||||||
|
|
||||||
### 스타일링
|
### 스타일링
|
||||||
|
|
||||||
- Tailwind CSS 유틸리티 클래스 사용
|
- Tailwind CSS 유틸리티 클래스 사용
|
||||||
- 커스텀 CSS는 최소화
|
- 커스텀 CSS는 최소화
|
||||||
- 반응형 디자인 고려
|
- 반응형 디자인 고려
|
||||||
- 일관된 컬러 팔레트 사용
|
- 일관된 컬러 팔레트 사용
|
||||||
|
|
||||||
### 폴더 및 파일 명명
|
### 폴더 및 파일 명명
|
||||||
|
|
||||||
- 컴포넌트: PascalCase (예: `TransactionCard.tsx`)
|
- 컴포넌트: PascalCase (예: `TransactionCard.tsx`)
|
||||||
- 훅: camelCase with 'use' prefix (예: `useTransactions.ts`)
|
- 훅: camelCase with 'use' prefix (예: `useTransactions.ts`)
|
||||||
- 유틸리티: camelCase (예: `formatCurrency.ts`)
|
- 유틸리티: camelCase (예: `formatCurrency.ts`)
|
||||||
@@ -141,17 +156,20 @@ task-master set-status --id=<id> --status=done # 작업 완료 표시
|
|||||||
## 테스트 전략
|
## 테스트 전략
|
||||||
|
|
||||||
### 단위 테스트
|
### 단위 테스트
|
||||||
|
|
||||||
- 모든 유틸리티 함수 테스트
|
- 모든 유틸리티 함수 테스트
|
||||||
- 컴포넌트 렌더링 테스트
|
- 컴포넌트 렌더링 테스트
|
||||||
- 사용자 상호작용 테스트
|
- 사용자 상호작용 테스트
|
||||||
- 에러 케이스 테스트
|
- 에러 케이스 테스트
|
||||||
|
|
||||||
### 통합 테스트
|
### 통합 테스트
|
||||||
|
|
||||||
- API 호출 흐름 테스트
|
- API 호출 흐름 테스트
|
||||||
- 상태 관리 통합 테스트
|
- 상태 관리 통합 테스트
|
||||||
- 라우팅 테스트
|
- 라우팅 테스트
|
||||||
|
|
||||||
### 테스트 커버리지 목표
|
### 테스트 커버리지 목표
|
||||||
|
|
||||||
- 라인 커버리지: 80% 이상
|
- 라인 커버리지: 80% 이상
|
||||||
- 함수 커버리지: 70% 이상
|
- 함수 커버리지: 70% 이상
|
||||||
- 브랜치 커버리지: 70% 이상
|
- 브랜치 커버리지: 70% 이상
|
||||||
@@ -174,12 +192,14 @@ NODE_ENV=development
|
|||||||
## 성능 최적화
|
## 성능 최적화
|
||||||
|
|
||||||
### 현재 적용된 최적화
|
### 현재 적용된 최적화
|
||||||
|
|
||||||
- React.lazy를 통한 컴포넌트 지연 로딩
|
- React.lazy를 통한 컴포넌트 지연 로딩
|
||||||
- React.memo를 통한 불필요한 리렌더링 방지
|
- React.memo를 통한 불필요한 리렌더링 방지
|
||||||
- useMemo, useCallback을 통한 계산 최적화
|
- useMemo, useCallback을 통한 계산 최적화
|
||||||
- 이미지 지연 로딩
|
- 이미지 지연 로딩
|
||||||
|
|
||||||
### 예정된 최적화
|
### 예정된 최적화
|
||||||
|
|
||||||
- 번들 크기 최적화
|
- 번들 크기 최적화
|
||||||
- 코드 스플리팅 개선
|
- 코드 스플리팅 개선
|
||||||
- 메모리 사용량 최적화
|
- 메모리 사용량 최적화
|
||||||
@@ -188,10 +208,12 @@ NODE_ENV=development
|
|||||||
## 배포 및 CI/CD
|
## 배포 및 CI/CD
|
||||||
|
|
||||||
### 배포 환경
|
### 배포 환경
|
||||||
|
|
||||||
- **개발**: Vite 개발 서버
|
- **개발**: Vite 개발 서버
|
||||||
- **프로덕션**: 정적 파일 빌드 후 호스팅
|
- **프로덕션**: 정적 파일 빌드 후 호스팅
|
||||||
|
|
||||||
### CI/CD 파이프라인
|
### CI/CD 파이프라인
|
||||||
|
|
||||||
- 코드 품질 검사 (ESLint, Prettier)
|
- 코드 품질 검사 (ESLint, Prettier)
|
||||||
- 자동 테스트 실행
|
- 자동 테스트 실행
|
||||||
- 타입 체크
|
- 타입 체크
|
||||||
@@ -219,6 +241,7 @@ NODE_ENV=development
|
|||||||
## 기여 가이드
|
## 기여 가이드
|
||||||
|
|
||||||
### 개발 워크플로우
|
### 개발 워크플로우
|
||||||
|
|
||||||
1. 작업 브랜치 생성
|
1. 작업 브랜치 생성
|
||||||
2. Task Master에서 작업 선택
|
2. Task Master에서 작업 선택
|
||||||
3. 코드 작성 및 테스트
|
3. 코드 작성 및 테스트
|
||||||
@@ -226,6 +249,7 @@ NODE_ENV=development
|
|||||||
5. 머지 후 배포
|
5. 머지 후 배포
|
||||||
|
|
||||||
### 코드 리뷰 체크리스트
|
### 코드 리뷰 체크리스트
|
||||||
|
|
||||||
- [ ] TypeScript 타입 안전성
|
- [ ] TypeScript 타입 안전성
|
||||||
- [ ] 테스트 커버리지
|
- [ ] 테스트 커버리지
|
||||||
- [ ] 성능 최적화
|
- [ ] 성능 최적화
|
||||||
@@ -235,6 +259,7 @@ NODE_ENV=development
|
|||||||
## 추가 리소스
|
## 추가 리소스
|
||||||
|
|
||||||
### 관련 문서
|
### 관련 문서
|
||||||
|
|
||||||
- [React 공식 문서](https://react.dev/)
|
- [React 공식 문서](https://react.dev/)
|
||||||
- [TypeScript 핸드북](https://www.typescriptlang.org/docs/)
|
- [TypeScript 핸드북](https://www.typescriptlang.org/docs/)
|
||||||
- [Tailwind CSS 문서](https://tailwindcss.com/docs)
|
- [Tailwind CSS 문서](https://tailwindcss.com/docs)
|
||||||
@@ -242,6 +267,7 @@ NODE_ENV=development
|
|||||||
- [Vitest 문서](https://vitest.dev/)
|
- [Vitest 문서](https://vitest.dev/)
|
||||||
|
|
||||||
### 프로젝트 관리
|
### 프로젝트 관리
|
||||||
|
|
||||||
- Task Master AI를 통한 작업 추적
|
- Task Master AI를 통한 작업 추적
|
||||||
- 이슈 및 버그 리포팅
|
- 이슈 및 버그 리포팅
|
||||||
- 기능 요청 및 개선 사항
|
- 기능 요청 및 개선 사항
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# Zellyy Finance - Vercel 배포 가이드
|
# Zellyy Finance - Vercel 배포 가이드
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
이 문서는 Zellyy Finance 프로젝트를 Vercel에서 자동 배포하는 방법에 대한 가이드입니다.
|
이 문서는 Zellyy Finance 프로젝트를 Vercel에서 자동 배포하는 방법에 대한 가이드입니다.
|
||||||
|
|
||||||
## 사전 준비사항
|
## 사전 준비사항
|
||||||
|
|
||||||
- GitHub 저장소가 생성되어 있어야 함
|
- GitHub 저장소가 생성되어 있어야 함
|
||||||
- Vercel 계정이 필요함
|
- Vercel 계정이 필요함
|
||||||
- Appwrite 프로젝트가 설정되어 있어야 함
|
- Appwrite 프로젝트가 설정되어 있어야 함
|
||||||
@@ -11,16 +13,19 @@
|
|||||||
## 1. Vercel 프로젝트 생성 및 연결
|
## 1. Vercel 프로젝트 생성 및 연결
|
||||||
|
|
||||||
### 1.1 Vercel 계정 로그인
|
### 1.1 Vercel 계정 로그인
|
||||||
|
|
||||||
1. [Vercel 웹사이트](https://vercel.com)에 접속
|
1. [Vercel 웹사이트](https://vercel.com)에 접속
|
||||||
2. GitHub 계정으로 로그인
|
2. GitHub 계정으로 로그인
|
||||||
3. "New Project" 버튼 클릭
|
3. "New Project" 버튼 클릭
|
||||||
|
|
||||||
### 1.2 GitHub 저장소 연결
|
### 1.2 GitHub 저장소 연결
|
||||||
|
|
||||||
1. Import Git Repository 섹션에서 GitHub 선택
|
1. Import Git Repository 섹션에서 GitHub 선택
|
||||||
2. `zellyy-finance` 저장소 선택
|
2. `zellyy-finance` 저장소 선택
|
||||||
3. "Import" 버튼 클릭
|
3. "Import" 버튼 클릭
|
||||||
|
|
||||||
### 1.3 프로젝트 설정
|
### 1.3 프로젝트 설정
|
||||||
|
|
||||||
- **Framework Preset**: Vite (자동 감지됨)
|
- **Framework Preset**: Vite (자동 감지됨)
|
||||||
- **Root Directory**: `.` (기본값)
|
- **Root Directory**: `.` (기본값)
|
||||||
- **Build Command**: `npm run build` (자동 설정됨)
|
- **Build Command**: `npm run build` (자동 설정됨)
|
||||||
@@ -30,9 +35,11 @@
|
|||||||
## 2. 환경 변수 설정
|
## 2. 환경 변수 설정
|
||||||
|
|
||||||
### 2.1 필수 환경 변수
|
### 2.1 필수 환경 변수
|
||||||
|
|
||||||
Vercel 대시보드의 Settings > Environment Variables에서 다음 변수들을 설정:
|
Vercel 대시보드의 Settings > Environment Variables에서 다음 변수들을 설정:
|
||||||
|
|
||||||
#### 프로덕션 환경 (Production)
|
#### 프로덕션 환경 (Production)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
||||||
VITE_APPWRITE_PROJECT_ID=your-production-project-id
|
VITE_APPWRITE_PROJECT_ID=your-production-project-id
|
||||||
@@ -43,6 +50,7 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 프리뷰 환경 (Preview)
|
#### 프리뷰 환경 (Preview)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
||||||
VITE_APPWRITE_PROJECT_ID=your-preview-project-id
|
VITE_APPWRITE_PROJECT_ID=your-preview-project-id
|
||||||
@@ -53,24 +61,30 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 환경별 브랜치 매핑
|
### 2.2 환경별 브랜치 매핑
|
||||||
|
|
||||||
- **Production**: `main` 브랜치
|
- **Production**: `main` 브랜치
|
||||||
- **Preview**: `develop` 브랜치 및 PR 브랜치들
|
- **Preview**: `develop` 브랜치 및 PR 브랜치들
|
||||||
|
|
||||||
## 3. 배포 설정 최적화
|
## 3. 배포 설정 최적화
|
||||||
|
|
||||||
### 3.1 Node.js 버전 설정
|
### 3.1 Node.js 버전 설정
|
||||||
|
|
||||||
`.nvmrc` 파일에서 Node.js 버전 지정:
|
`.nvmrc` 파일에서 Node.js 버전 지정:
|
||||||
|
|
||||||
```
|
```
|
||||||
18.x
|
18.x
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 빌드 최적화
|
### 3.2 빌드 최적화
|
||||||
|
|
||||||
- 코드 스플리팅이 이미 구현되어 있음 (React.lazy)
|
- 코드 스플리팅이 이미 구현되어 있음 (React.lazy)
|
||||||
- 정적 자산 캐싱 설정 (`vercel.json`에 포함됨)
|
- 정적 자산 캐싱 설정 (`vercel.json`에 포함됨)
|
||||||
- 브라우저 호환성 최적화
|
- 브라우저 호환성 최적화
|
||||||
|
|
||||||
### 3.3 보안 헤더 설정
|
### 3.3 보안 헤더 설정
|
||||||
|
|
||||||
`vercel.json`에 다음 보안 헤더들이 설정됨:
|
`vercel.json`에 다음 보안 헤더들이 설정됨:
|
||||||
|
|
||||||
- X-Content-Type-Options: nosniff
|
- X-Content-Type-Options: nosniff
|
||||||
- X-Frame-Options: DENY
|
- X-Frame-Options: DENY
|
||||||
- X-XSS-Protection: 1; mode=block
|
- X-XSS-Protection: 1; mode=block
|
||||||
@@ -79,18 +93,21 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
## 4. 자동 배포 워크플로우
|
## 4. 자동 배포 워크플로우
|
||||||
|
|
||||||
### 4.1 Production 배포
|
### 4.1 Production 배포
|
||||||
|
|
||||||
1. `main` 브랜치에 코드 푸시
|
1. `main` 브랜치에 코드 푸시
|
||||||
2. Vercel이 자동으로 빌드 시작
|
2. Vercel이 자동으로 빌드 시작
|
||||||
3. 빌드 성공 시 Production 환경에 배포
|
3. 빌드 성공 시 Production 환경에 배포
|
||||||
4. 실패 시 이전 버전 유지
|
4. 실패 시 이전 버전 유지
|
||||||
|
|
||||||
### 4.2 Preview 배포
|
### 4.2 Preview 배포
|
||||||
|
|
||||||
1. `develop` 브랜치 또는 PR 생성
|
1. `develop` 브랜치 또는 PR 생성
|
||||||
2. 자동으로 Preview 배포 생성
|
2. 자동으로 Preview 배포 생성
|
||||||
3. 고유한 URL로 미리보기 제공
|
3. 고유한 URL로 미리보기 제공
|
||||||
4. PR에 배포 링크 자동 코멘트
|
4. PR에 배포 링크 자동 코멘트
|
||||||
|
|
||||||
### 4.3 배포 상태 모니터링
|
### 4.3 배포 상태 모니터링
|
||||||
|
|
||||||
- Vercel 대시보드에서 실시간 빌드 로그 확인
|
- Vercel 대시보드에서 실시간 빌드 로그 확인
|
||||||
- GitHub PR에 배포 상태 자동 업데이트
|
- GitHub PR에 배포 상태 자동 업데이트
|
||||||
- 배포 실패 시 슬랙/이메일 알림 (선택사항)
|
- 배포 실패 시 슬랙/이메일 알림 (선택사항)
|
||||||
@@ -98,23 +115,27 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
## 5. 도메인 설정
|
## 5. 도메인 설정
|
||||||
|
|
||||||
### 5.1 커스텀 도메인 연결
|
### 5.1 커스텀 도메인 연결
|
||||||
|
|
||||||
1. Vercel 프로젝트 Settings > Domains
|
1. Vercel 프로젝트 Settings > Domains
|
||||||
2. 원하는 도메인 입력
|
2. 원하는 도메인 입력
|
||||||
3. DNS 설정 업데이트 (CNAME 또는 A 레코드)
|
3. DNS 설정 업데이트 (CNAME 또는 A 레코드)
|
||||||
4. SSL 인증서 자동 설정
|
4. SSL 인증서 자동 설정
|
||||||
|
|
||||||
### 5.2 도메인 예시
|
### 5.2 도메인 예시
|
||||||
|
|
||||||
- Production: `zellyy-finance.vercel.app` 또는 `your-custom-domain.com`
|
- Production: `zellyy-finance.vercel.app` 또는 `your-custom-domain.com`
|
||||||
- Preview: `zellyy-finance-git-develop-username.vercel.app`
|
- Preview: `zellyy-finance-git-develop-username.vercel.app`
|
||||||
|
|
||||||
## 6. 성능 최적화
|
## 6. 성능 최적화
|
||||||
|
|
||||||
### 6.1 분석 도구
|
### 6.1 분석 도구
|
||||||
|
|
||||||
- Vercel Analytics 활성화
|
- Vercel Analytics 활성화
|
||||||
- Core Web Vitals 모니터링
|
- Core Web Vitals 모니터링
|
||||||
- 번들 크기 분석
|
- 번들 크기 분석
|
||||||
|
|
||||||
### 6.2 최적화된 설정
|
### 6.2 최적화된 설정
|
||||||
|
|
||||||
- 이미지 최적화 (Vercel Image Optimization)
|
- 이미지 최적화 (Vercel Image Optimization)
|
||||||
- 정적 자산 CDN 캐싱
|
- 정적 자산 CDN 캐싱
|
||||||
- 압축 및 minification 자동 적용
|
- 압축 및 minification 자동 적용
|
||||||
@@ -122,11 +143,13 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
## 7. 트러블슈팅
|
## 7. 트러블슈팅
|
||||||
|
|
||||||
### 7.1 일반적인 문제들
|
### 7.1 일반적인 문제들
|
||||||
|
|
||||||
- **빌드 실패**: Node.js 버전 호환성 확인
|
- **빌드 실패**: Node.js 버전 호환성 확인
|
||||||
- **환경변수 오류**: Vercel 대시보드에서 변수 설정 확인
|
- **환경변수 오류**: Vercel 대시보드에서 변수 설정 확인
|
||||||
- **라우팅 오류**: SPA rewrites 설정 확인 (`vercel.json`)
|
- **라우팅 오류**: SPA rewrites 설정 확인 (`vercel.json`)
|
||||||
|
|
||||||
### 7.2 디버깅 팁
|
### 7.2 디버깅 팁
|
||||||
|
|
||||||
- Vercel 빌드 로그 자세히 확인
|
- Vercel 빌드 로그 자세히 확인
|
||||||
- 로컬에서 `npm run build` 테스트
|
- 로컬에서 `npm run build` 테스트
|
||||||
- 환경변수 값이 올바른지 확인
|
- 환경변수 값이 올바른지 확인
|
||||||
@@ -134,16 +157,19 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
## 8. 추가 기능
|
## 8. 추가 기능
|
||||||
|
|
||||||
### 8.1 Branch Protection
|
### 8.1 Branch Protection
|
||||||
|
|
||||||
- `main` 브랜치에 대한 보호 규칙 설정
|
- `main` 브랜치에 대한 보호 규칙 설정
|
||||||
- PR 리뷰 필수화
|
- PR 리뷰 필수화
|
||||||
- 배포 전 테스트 통과 필수
|
- 배포 전 테스트 통과 필수
|
||||||
|
|
||||||
### 8.2 모니터링 및 알림
|
### 8.2 모니터링 및 알림
|
||||||
|
|
||||||
- 배포 상태 Slack 알림
|
- 배포 상태 Slack 알림
|
||||||
- 성능 저하 감지 알림
|
- 성능 저하 감지 알림
|
||||||
- 에러 추적 (Sentry 연동 가능)
|
- 에러 추적 (Sentry 연동 가능)
|
||||||
|
|
||||||
## 참고 자료
|
## 참고 자료
|
||||||
|
|
||||||
- [Vercel 공식 문서](https://vercel.com/docs)
|
- [Vercel 공식 문서](https://vercel.com/docs)
|
||||||
- [Vite 배포 가이드](https://vitejs.dev/guide/static-deploy.html)
|
- [Vite 배포 가이드](https://vitejs.dev/guide/static-deploy.html)
|
||||||
- [React Router SPA 설정](https://reactrouter.com/en/main/guides/ssr)
|
- [React Router SPA 설정](https://reactrouter.com/en/main/guides/ssr)
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
## 📋 배포 전 준비사항
|
## 📋 배포 전 준비사항
|
||||||
|
|
||||||
### ✅ 코드 준비
|
### ✅ 코드 준비
|
||||||
|
|
||||||
- [ ] 모든 테스트 통과 (`npm run test:run`)
|
- [ ] 모든 테스트 통과 (`npm run test:run`)
|
||||||
- [ ] 타입 검사 통과 (`npm run type-check`)
|
- [ ] 타입 검사 통과 (`npm run type-check`)
|
||||||
- [ ] 린트 검사 통과 (`npm run lint`)
|
- [ ] 린트 검사 통과 (`npm run lint`)
|
||||||
@@ -12,12 +13,14 @@
|
|||||||
- [ ] 성능 최적화 확인 (코드 스플리팅, 메모이제이션)
|
- [ ] 성능 최적화 확인 (코드 스플리팅, 메모이제이션)
|
||||||
|
|
||||||
### ✅ 환경 설정
|
### ✅ 환경 설정
|
||||||
|
|
||||||
- [ ] `.env.example` 파일 최신 상태 유지
|
- [ ] `.env.example` 파일 최신 상태 유지
|
||||||
- [ ] 프로덕션용 Appwrite 프로젝트 설정 완료
|
- [ ] 프로덕션용 Appwrite 프로젝트 설정 완료
|
||||||
- [ ] 프리뷰용 Appwrite 프로젝트 설정 완료 (선택사항)
|
- [ ] 프리뷰용 Appwrite 프로젝트 설정 완료 (선택사항)
|
||||||
- [ ] 필수 환경 변수 목록 확인
|
- [ ] 필수 환경 변수 목록 확인
|
||||||
|
|
||||||
### ✅ GitHub 설정
|
### ✅ GitHub 설정
|
||||||
|
|
||||||
- [ ] GitHub 저장소가 public 또는 Vercel 연동 가능한 상태
|
- [ ] GitHub 저장소가 public 또는 Vercel 연동 가능한 상태
|
||||||
- [ ] `main` 브랜치가 안정적인 상태
|
- [ ] `main` 브랜치가 안정적인 상태
|
||||||
- [ ] PR 템플릿이 설정됨
|
- [ ] PR 템플릿이 설정됨
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
## 🔧 Vercel 프로젝트 설정
|
## 🔧 Vercel 프로젝트 설정
|
||||||
|
|
||||||
### 1단계: Vercel 계정 및 프로젝트 생성
|
### 1단계: Vercel 계정 및 프로젝트 생성
|
||||||
|
|
||||||
- [ ] [Vercel 웹사이트](https://vercel.com)에서 GitHub 계정으로 로그인
|
- [ ] [Vercel 웹사이트](https://vercel.com)에서 GitHub 계정으로 로그인
|
||||||
- [ ] "New Project" 클릭
|
- [ ] "New Project" 클릭
|
||||||
- [ ] `zellyy-finance` 저장소 선택하여 Import
|
- [ ] `zellyy-finance` 저장소 선택하여 Import
|
||||||
@@ -37,9 +41,11 @@
|
|||||||
- Install Command: `npm install`
|
- Install Command: `npm install`
|
||||||
|
|
||||||
### 2단계: 환경 변수 설정
|
### 2단계: 환경 변수 설정
|
||||||
|
|
||||||
Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
||||||
|
|
||||||
#### 🔑 프로덕션 환경 변수
|
#### 🔑 프로덕션 환경 변수
|
||||||
|
|
||||||
- [ ] `VITE_APPWRITE_ENDPOINT` - Appwrite 엔드포인트 URL
|
- [ ] `VITE_APPWRITE_ENDPOINT` - Appwrite 엔드포인트 URL
|
||||||
- [ ] `VITE_APPWRITE_PROJECT_ID` - 프로덕션 프로젝트 ID
|
- [ ] `VITE_APPWRITE_PROJECT_ID` - 프로덕션 프로젝트 ID
|
||||||
- [ ] `VITE_APPWRITE_DATABASE_ID` - 데이터베이스 ID (default)
|
- [ ] `VITE_APPWRITE_DATABASE_ID` - 데이터베이스 ID (default)
|
||||||
@@ -48,10 +54,12 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
- [ ] `VITE_DISABLE_LOVABLE_BANNER` - `true` 설정
|
- [ ] `VITE_DISABLE_LOVABLE_BANNER` - `true` 설정
|
||||||
|
|
||||||
#### 🔑 프리뷰 환경 변수 (동일한 키, 다른 값)
|
#### 🔑 프리뷰 환경 변수 (동일한 키, 다른 값)
|
||||||
|
|
||||||
- [ ] 프리뷰용 Appwrite 프로젝트 ID 설정
|
- [ ] 프리뷰용 Appwrite 프로젝트 ID 설정
|
||||||
- [ ] 기타 환경 변수는 프로덕션과 동일
|
- [ ] 기타 환경 변수는 프로덕션과 동일
|
||||||
|
|
||||||
### 3단계: 브랜치 및 배포 설정
|
### 3단계: 브랜치 및 배포 설정
|
||||||
|
|
||||||
- [ ] Production 브랜치: `main` 확인
|
- [ ] Production 브랜치: `main` 확인
|
||||||
- [ ] Preview 브랜치: `develop` 및 모든 PR 브랜치 확인
|
- [ ] Preview 브랜치: `develop` 및 모든 PR 브랜치 확인
|
||||||
- [ ] 자동 배포 활성화 확인
|
- [ ] 자동 배포 활성화 확인
|
||||||
@@ -59,12 +67,14 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 🚀 첫 배포 실행
|
## 🚀 첫 배포 실행
|
||||||
|
|
||||||
### 배포 테스트
|
### 배포 테스트
|
||||||
|
|
||||||
- [ ] 첫 번째 배포 실행 (자동 또는 수동)
|
- [ ] 첫 번째 배포 실행 (자동 또는 수동)
|
||||||
- [ ] Vercel 대시보드에서 빌드 로그 확인
|
- [ ] Vercel 대시보드에서 빌드 로그 확인
|
||||||
- [ ] 배포 성공 확인
|
- [ ] 배포 성공 확인
|
||||||
- [ ] 생성된 URL에서 애플리케이션 정상 동작 확인
|
- [ ] 생성된 URL에서 애플리케이션 정상 동작 확인
|
||||||
|
|
||||||
### 기능 테스트
|
### 기능 테스트
|
||||||
|
|
||||||
- [ ] 로그인/회원가입 기능 테스트
|
- [ ] 로그인/회원가입 기능 테스트
|
||||||
- [ ] 거래 내역 추가/수정/삭제 테스트
|
- [ ] 거래 내역 추가/수정/삭제 테스트
|
||||||
- [ ] 예산 설정 기능 테스트
|
- [ ] 예산 설정 기능 테스트
|
||||||
@@ -74,12 +84,14 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 🔄 자동 배포 워크플로우 검증
|
## 🔄 자동 배포 워크플로우 검증
|
||||||
|
|
||||||
### GitHub Actions 확인
|
### GitHub Actions 확인
|
||||||
|
|
||||||
- [ ] PR 생성 시 자동 빌드 실행 확인
|
- [ ] PR 생성 시 자동 빌드 실행 확인
|
||||||
- [ ] 배포 전 테스트 자동 실행 확인
|
- [ ] 배포 전 테스트 자동 실행 확인
|
||||||
- [ ] 보안 스캔 자동 실행 확인
|
- [ ] 보안 스캔 자동 실행 확인
|
||||||
- [ ] 빌드 실패 시 알림 확인
|
- [ ] 빌드 실패 시 알림 확인
|
||||||
|
|
||||||
### Vercel 통합 확인
|
### Vercel 통합 확인
|
||||||
|
|
||||||
- [ ] `main` 브랜치 푸시 시 프로덕션 자동 배포
|
- [ ] `main` 브랜치 푸시 시 프로덕션 자동 배포
|
||||||
- [ ] PR 생성 시 프리뷰 배포 자동 생성
|
- [ ] PR 생성 시 프리뷰 배포 자동 생성
|
||||||
- [ ] 배포 상태가 GitHub PR에 자동 코멘트됨
|
- [ ] 배포 상태가 GitHub PR에 자동 코멘트됨
|
||||||
@@ -88,6 +100,7 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 🌐 도메인 설정 (선택사항)
|
## 🌐 도메인 설정 (선택사항)
|
||||||
|
|
||||||
### 커스텀 도메인 연결
|
### 커스텀 도메인 연결
|
||||||
|
|
||||||
- [ ] Vercel 프로젝트 Settings > Domains 접속
|
- [ ] Vercel 프로젝트 Settings > Domains 접속
|
||||||
- [ ] 원하는 도메인 입력
|
- [ ] 원하는 도메인 입력
|
||||||
- [ ] DNS 설정 업데이트 (CNAME 또는 A 레코드)
|
- [ ] DNS 설정 업데이트 (CNAME 또는 A 레코드)
|
||||||
@@ -97,12 +110,14 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 📊 성능 및 모니터링 설정
|
## 📊 성능 및 모니터링 설정
|
||||||
|
|
||||||
### 성능 최적화 확인
|
### 성능 최적화 확인
|
||||||
|
|
||||||
- [ ] 코드 스플리팅이 적용됨 (청크 파일들 확인)
|
- [ ] 코드 스플리팅이 적용됨 (청크 파일들 확인)
|
||||||
- [ ] 이미지 최적화 적용 확인
|
- [ ] 이미지 최적화 적용 확인
|
||||||
- [ ] 정적 자산 캐싱 설정 확인
|
- [ ] 정적 자산 캐싱 설정 확인
|
||||||
- [ ] 압축 및 minification 적용 확인
|
- [ ] 압축 및 minification 적용 확인
|
||||||
|
|
||||||
### 모니터링 설정
|
### 모니터링 설정
|
||||||
|
|
||||||
- [ ] Vercel Analytics 활성화 (선택사항)
|
- [ ] Vercel Analytics 활성화 (선택사항)
|
||||||
- [ ] Core Web Vitals 모니터링 설정
|
- [ ] Core Web Vitals 모니터링 설정
|
||||||
- [ ] 에러 추적 설정 (Sentry 등, 선택사항)
|
- [ ] 에러 추적 설정 (Sentry 등, 선택사항)
|
||||||
@@ -111,12 +126,14 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 🔒 보안 및 안정성 체크
|
## 🔒 보안 및 안정성 체크
|
||||||
|
|
||||||
### 보안 설정 확인
|
### 보안 설정 확인
|
||||||
|
|
||||||
- [ ] 환경 변수가 빌드 파일에 노출되지 않음
|
- [ ] 환경 변수가 빌드 파일에 노출되지 않음
|
||||||
- [ ] HTTPS 강제 리다이렉트 설정
|
- [ ] HTTPS 강제 리다이렉트 설정
|
||||||
- [ ] 보안 헤더 설정 확인 (`vercel.json`)
|
- [ ] 보안 헤더 설정 확인 (`vercel.json`)
|
||||||
- [ ] npm audit 보안 취약점 없음
|
- [ ] npm audit 보안 취약점 없음
|
||||||
|
|
||||||
### 백업 및 롤백 준비
|
### 백업 및 롤백 준비
|
||||||
|
|
||||||
- [ ] 이전 배포 버전 롤백 방법 숙지
|
- [ ] 이전 배포 버전 롤백 방법 숙지
|
||||||
- [ ] 데이터베이스 백업 계획 수립
|
- [ ] 데이터베이스 백업 계획 수립
|
||||||
- [ ] 장애 상황 대응 계획 수립
|
- [ ] 장애 상황 대응 계획 수립
|
||||||
@@ -124,6 +141,7 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
## 📋 배포 완료 체크리스트
|
## 📋 배포 완료 체크리스트
|
||||||
|
|
||||||
### 최종 확인
|
### 최종 확인
|
||||||
|
|
||||||
- [ ] 프로덕션 URL에서 모든 기능 정상 동작
|
- [ ] 프로덕션 URL에서 모든 기능 정상 동작
|
||||||
- [ ] 모바일 디바이스에서 접속 테스트
|
- [ ] 모바일 디바이스에서 접속 테스트
|
||||||
- [ ] 다양한 브라우저에서 호환성 확인
|
- [ ] 다양한 브라우저에서 호환성 확인
|
||||||
@@ -131,6 +149,7 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
- [ ] 사용자 피드백 수집 준비
|
- [ ] 사용자 피드백 수집 준비
|
||||||
|
|
||||||
### 문서화
|
### 문서화
|
||||||
|
|
||||||
- [ ] 배포 URL 및 접속 정보 공유
|
- [ ] 배포 URL 및 접속 정보 공유
|
||||||
- [ ] 배포 과정 문서 업데이트
|
- [ ] 배포 과정 문서 업데이트
|
||||||
- [ ] 트러블슈팅 가이드 작성
|
- [ ] 트러블슈팅 가이드 작성
|
||||||
@@ -141,25 +160,30 @@ Vercel 프로젝트 Settings > Environment Variables에서 설정:
|
|||||||
### 일반적인 문제들
|
### 일반적인 문제들
|
||||||
|
|
||||||
#### 빌드 실패
|
#### 빌드 실패
|
||||||
|
|
||||||
- Node.js 버전 호환성 확인
|
- Node.js 버전 호환성 확인
|
||||||
- 의존성 설치 문제 (`npm ci` 재실행)
|
- 의존성 설치 문제 (`npm ci` 재실행)
|
||||||
- 환경 변수 오타 확인
|
- 환경 변수 오타 확인
|
||||||
|
|
||||||
#### 환경 변수 오류
|
#### 환경 변수 오류
|
||||||
|
|
||||||
- Vercel 대시보드에서 변수 값 확인
|
- Vercel 대시보드에서 변수 값 확인
|
||||||
- 대소문자 및 오타 확인
|
- 대소문자 및 오타 확인
|
||||||
- 환경별 설정 확인 (Production/Preview)
|
- 환경별 설정 확인 (Production/Preview)
|
||||||
|
|
||||||
#### 라우팅 문제
|
#### 라우팅 문제
|
||||||
|
|
||||||
- `vercel.json`의 rewrites 설정 확인
|
- `vercel.json`의 rewrites 설정 확인
|
||||||
- SPA 라우팅 설정 확인
|
- SPA 라우팅 설정 확인
|
||||||
|
|
||||||
#### 성능 문제
|
#### 성능 문제
|
||||||
|
|
||||||
- 번들 크기 분석 (`npm run build:analyze`)
|
- 번들 크기 분석 (`npm run build:analyze`)
|
||||||
- 코드 스플리팅 적용 확인
|
- 코드 스플리팅 적용 확인
|
||||||
- 이미지 최적화 확인
|
- 이미지 최적화 확인
|
||||||
|
|
||||||
### 도움말 및 지원
|
### 도움말 및 지원
|
||||||
|
|
||||||
- [Vercel 공식 문서](https://vercel.com/docs)
|
- [Vercel 공식 문서](https://vercel.com/docs)
|
||||||
- [GitHub Issues](https://github.com/hansoo./zellyy-finance/issues)
|
- [GitHub Issues](https://github.com/hansoo./zellyy-finance/issues)
|
||||||
- [DEPLOYMENT.md](./DEPLOYMENT.md) 상세 가이드
|
- [DEPLOYMENT.md](./DEPLOYMENT.md) 상세 가이드
|
||||||
|
|||||||
@@ -104,14 +104,17 @@ npx tsc --noEmit
|
|||||||
이 프로젝트는 Vercel을 통해 자동 배포됩니다.
|
이 프로젝트는 Vercel을 통해 자동 배포됩니다.
|
||||||
|
|
||||||
### 자동 배포
|
### 자동 배포
|
||||||
|
|
||||||
- **프로덕션**: `main` 브랜치에 푸시하면 자동으로 프로덕션 배포
|
- **프로덕션**: `main` 브랜치에 푸시하면 자동으로 프로덕션 배포
|
||||||
- **프리뷰**: PR 생성 시 자동으로 미리보기 배포 생성
|
- **프리뷰**: PR 생성 시 자동으로 미리보기 배포 생성
|
||||||
- **스테이징**: `develop` 브랜치는 스테이징 환경으로 배포
|
- **스테이징**: `develop` 브랜치는 스테이징 환경으로 배포
|
||||||
|
|
||||||
### 배포 설정
|
### 배포 설정
|
||||||
|
|
||||||
자세한 배포 설정 방법은 [DEPLOYMENT.md](./DEPLOYMENT.md)를 참조하세요.
|
자세한 배포 설정 방법은 [DEPLOYMENT.md](./DEPLOYMENT.md)를 참조하세요.
|
||||||
|
|
||||||
### 필수 환경 변수
|
### 필수 환경 변수
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
VITE_APPWRITE_ENDPOINT=https://your-appwrite-endpoint/v1
|
||||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||||
@@ -124,6 +127,7 @@ VITE_DISABLE_LOVABLE_BANNER=true
|
|||||||
## 🔗 커스텀 도메인
|
## 🔗 커스텀 도메인
|
||||||
|
|
||||||
Vercel을 통해 커스텀 도메인을 쉽게 연결할 수 있습니다:
|
Vercel을 통해 커스텀 도메인을 쉽게 연결할 수 있습니다:
|
||||||
|
|
||||||
1. Vercel 프로젝트 Settings > Domains
|
1. Vercel 프로젝트 Settings > Domains
|
||||||
2. 원하는 도메인 입력
|
2. 원하는 도메인 입력
|
||||||
3. DNS 설정 업데이트
|
3. DNS 설정 업데이트
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# React 성능 최적화 분석 보고서
|
# React 성능 최적화 분석 보고서
|
||||||
|
|
||||||
## 성능 분석 개요
|
## 성능 분석 개요
|
||||||
|
|
||||||
- 분석 일시: 2025-07-12
|
- 분석 일시: 2025-07-12
|
||||||
- 분석 도구: React DevTools Profiler, 코드 리뷰
|
- 분석 도구: React DevTools Profiler, 코드 리뷰
|
||||||
- 목표: 리렌더링 횟수 감소, 로딩 속도 2배 향상, 메모리 사용량 최적화
|
- 목표: 리렌더링 횟수 감소, 로딩 속도 2배 향상, 메모리 사용량 최적화
|
||||||
@@ -8,16 +9,19 @@
|
|||||||
## 발견된 성능 이슈
|
## 발견된 성능 이슈
|
||||||
|
|
||||||
### 1. 코드 스플리팅 미적용
|
### 1. 코드 스플리팅 미적용
|
||||||
|
|
||||||
- **문제**: 모든 페이지 컴포넌트가 동기적으로 import됨 (App.tsx:15-27)
|
- **문제**: 모든 페이지 컴포넌트가 동기적으로 import됨 (App.tsx:15-27)
|
||||||
- **영향**: 초기 번들 크기 증가, 첫 로딩 시간 지연
|
- **영향**: 초기 번들 크기 증가, 첫 로딩 시간 지연
|
||||||
- **해결방안**: React.lazy와 Suspense 적용
|
- **해결방안**: React.lazy와 Suspense 적용
|
||||||
|
|
||||||
### 2. 과도한 백그라운드 동기화
|
### 2. 과도한 백그라운드 동기화
|
||||||
|
|
||||||
- **문제**: BackgroundSync가 5분 간격으로 실행 (App.tsx:228)
|
- **문제**: BackgroundSync가 5분 간격으로 실행 (App.tsx:228)
|
||||||
- **영향**: 불필요한 API 호출, 배터리 소모
|
- **영향**: 불필요한 API 호출, 배터리 소모
|
||||||
- **해결방안**: 30초 간격으로 조정
|
- **해결방안**: 30초 간격으로 조정
|
||||||
|
|
||||||
### 3. 메모이제이션 미적용
|
### 3. 메모이제이션 미적용
|
||||||
|
|
||||||
- **문제**: 다음 컴포넌트들에서 불필요한 리렌더링 발생 가능
|
- **문제**: 다음 컴포넌트들에서 불필요한 리렌더링 발생 가능
|
||||||
- Header: 사용자 인증 상태 변경 시마다 재렌더링
|
- Header: 사용자 인증 상태 변경 시마다 재렌더링
|
||||||
- IndexContent: 스토어 상태 변경 시마다 재렌더링
|
- IndexContent: 스토어 상태 변경 시마다 재렌더링
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
- **해결방안**: React.memo, useMemo, useCallback 적용
|
- **해결방안**: React.memo, useMemo, useCallback 적용
|
||||||
|
|
||||||
### 4. 복잡한 useEffect 의존성
|
### 4. 복잡한 useEffect 의존성
|
||||||
|
|
||||||
- **문제**: Index.tsx에서 복잡한 의존성 배열 (라인 92-98)
|
- **문제**: Index.tsx에서 복잡한 의존성 배열 (라인 92-98)
|
||||||
- **영향**: 불필요한 effect 실행
|
- **영향**: 불필요한 effect 실행
|
||||||
- **해결방안**: useCallback으로 함수 메모이제이션
|
- **해결방안**: useCallback으로 함수 메모이제이션
|
||||||
@@ -32,22 +37,26 @@
|
|||||||
## 성능 최적화 계획
|
## 성능 최적화 계획
|
||||||
|
|
||||||
### Phase 1: 코드 스플리팅 (우선순위: 높음)
|
### Phase 1: 코드 스플리팅 (우선순위: 높음)
|
||||||
|
|
||||||
- [ ] 페이지 컴포넌트들을 React.lazy로 변환
|
- [ ] 페이지 컴포넌트들을 React.lazy로 변환
|
||||||
- [ ] Suspense boundary 추가
|
- [ ] Suspense boundary 추가
|
||||||
- [ ] 로딩 스피너 컴포넌트 개선
|
- [ ] 로딩 스피너 컴포넌트 개선
|
||||||
|
|
||||||
### Phase 2: 메모이제이션 적용 (우선순위: 높음)
|
### Phase 2: 메모이제이션 적용 (우선순위: 높음)
|
||||||
|
|
||||||
- [ ] Header 컴포넌트에 React.memo 적용
|
- [ ] Header 컴포넌트에 React.memo 적용
|
||||||
- [ ] IndexContent에서 props drilling 최적화
|
- [ ] IndexContent에서 props drilling 최적화
|
||||||
- [ ] BudgetProgressCard 메모이제이션
|
- [ ] BudgetProgressCard 메모이제이션
|
||||||
- [ ] 커스텀 훅에서 useCallback 적용
|
- [ ] 커스텀 훅에서 useCallback 적용
|
||||||
|
|
||||||
### Phase 3: 설정 최적화 (우선순위: 중간)
|
### Phase 3: 설정 최적화 (우선순위: 중간)
|
||||||
|
|
||||||
- [ ] BackgroundSync 간격 조정 (5분 → 30초)
|
- [ ] BackgroundSync 간격 조정 (5분 → 30초)
|
||||||
- [ ] 이미지 지연 로딩 구현
|
- [ ] 이미지 지연 로딩 구현
|
||||||
- [ ] 가상화된 리스트 검토
|
- [ ] 가상화된 리스트 검토
|
||||||
|
|
||||||
### Phase 4: 측정 및 검증 (우선순위: 높음)
|
### Phase 4: 측정 및 검증 (우선순위: 높음)
|
||||||
|
|
||||||
- [ ] React DevTools Profiler로 before/after 비교
|
- [ ] React DevTools Profiler로 before/after 비교
|
||||||
- [ ] Lighthouse 성능 점수 측정
|
- [ ] Lighthouse 성능 점수 측정
|
||||||
- [ ] 번들 크기 분석
|
- [ ] 번들 크기 분석
|
||||||
@@ -55,6 +64,7 @@
|
|||||||
## 구현 완료된 최적화
|
## 구현 완료된 최적화
|
||||||
|
|
||||||
### 1. 코드 스플리팅 ✅
|
### 1. 코드 스플리팅 ✅
|
||||||
|
|
||||||
- **적용**: React.lazy와 Suspense를 사용하여 모든 페이지 컴포넌트를 동적 로딩으로 변경
|
- **적용**: React.lazy와 Suspense를 사용하여 모든 페이지 컴포넌트를 동적 로딩으로 변경
|
||||||
- **결과**:
|
- **결과**:
|
||||||
- 메인 번들: 470.15 kB (전체 코드베이스)
|
- 메인 번들: 470.15 kB (전체 코드베이스)
|
||||||
@@ -63,22 +73,26 @@
|
|||||||
- **효과**: 초기 로딩 시 87% 번들 크기 감소 (470 kB → 62 kB)
|
- **효과**: 초기 로딩 시 87% 번들 크기 감소 (470 kB → 62 kB)
|
||||||
|
|
||||||
### 2. 메모이제이션 적용 ✅
|
### 2. 메모이제이션 적용 ✅
|
||||||
|
|
||||||
- **Header 컴포넌트**: React.memo, useMemo, useCallback 적용
|
- **Header 컴포넌트**: React.memo, useMemo, useCallback 적용
|
||||||
- **IndexContent 컴포넌트**: 전체 메모이제이션 및 props 최적화
|
- **IndexContent 컴포넌트**: 전체 메모이제이션 및 props 최적화
|
||||||
- **BudgetProgressCard 컴포넌트**: 이벤트 핸들러 및 상태 메모이제이션
|
- **BudgetProgressCard 컴포넌트**: 이벤트 핸들러 및 상태 메모이제이션
|
||||||
- **Index 페이지**: 복잡한 useEffect 의존성 최적화
|
- **Index 페이지**: 복잡한 useEffect 의존성 최적화
|
||||||
|
|
||||||
### 3. 성능 설정 최적화 ✅
|
### 3. 성능 설정 최적화 ✅
|
||||||
|
|
||||||
- **BackgroundSync 간격**: 5분 → 30초로 조정 (90% 감소)
|
- **BackgroundSync 간격**: 5분 → 30초로 조정 (90% 감소)
|
||||||
- **이미지 로딩**: 프리로딩 및 에러 핸들링 최적화
|
- **이미지 로딩**: 프리로딩 및 에러 핸들링 최적화
|
||||||
- **이벤트 리스너**: 메모이제이션으로 불필요한 리스너 재등록 방지
|
- **이벤트 리스너**: 메모이제이션으로 불필요한 리스너 재등록 방지
|
||||||
|
|
||||||
### 4. 테스트 검증 ✅
|
### 4. 테스트 검증 ✅
|
||||||
|
|
||||||
- **단위 테스트**: 229개 모든 테스트 통과
|
- **단위 테스트**: 229개 모든 테스트 통과
|
||||||
- **타입 검사**: TypeScript 컴파일 오류 없음
|
- **타입 검사**: TypeScript 컴파일 오류 없음
|
||||||
- **프로덕션 빌드**: 성공적으로 완료
|
- **프로덕션 빌드**: 성공적으로 완료
|
||||||
|
|
||||||
## 측정된 성능 개선 효과
|
## 측정된 성능 개선 효과
|
||||||
|
|
||||||
- **초기 번들 크기**: 87% 감소 (470 kB → 62 kB)
|
- **초기 번들 크기**: 87% 감소 (470 kB → 62 kB)
|
||||||
- **리렌더링 최적화**: 메모이제이션으로 불필요한 리렌더링 방지
|
- **리렌더링 최적화**: 메모이제이션으로 불필요한 리렌더링 방지
|
||||||
- **동기화 효율성**: 백그라운드 동기화 간격 90% 단축
|
- **동기화 효율성**: 백그라운드 동기화 간격 90% 단축
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
* 이 스크립트는 .env.example 파일을 기반으로 Vercel 환경 변수를 설정합니다.
|
* 이 스크립트는 .env.example 파일을 기반으로 Vercel 환경 변수를 설정합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require("child_process");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
|
|
||||||
const ENV_EXAMPLE_PATH = path.join(__dirname, '..', '.env.example');
|
const ENV_EXAMPLE_PATH = path.join(__dirname, "..", ".env.example");
|
||||||
|
|
||||||
function parseEnvFile(filePath) {
|
function parseEnvFile(filePath) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -17,15 +17,15 @@ function parseEnvFile(filePath) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
const envVars = {};
|
const envVars = {};
|
||||||
|
|
||||||
content.split('\n').forEach(line => {
|
content.split("\n").forEach((line) => {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line && !line.startsWith('#') && line.includes('=')) {
|
if (line && !line.startsWith("#") && line.includes("=")) {
|
||||||
const [key, ...values] = line.split('=');
|
const [key, ...values] = line.split("=");
|
||||||
if (key.startsWith('VITE_')) {
|
if (key.startsWith("VITE_")) {
|
||||||
envVars[key.trim()] = values.join('=').trim();
|
envVars[key.trim()] = values.join("=").trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -34,52 +34,52 @@ function parseEnvFile(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupVercelEnv() {
|
function setupVercelEnv() {
|
||||||
console.log('🚀 Vercel 환경 변수 설정을 시작합니다...');
|
console.log("🚀 Vercel 환경 변수 설정을 시작합니다...");
|
||||||
|
|
||||||
// Vercel CLI 설치 확인
|
// Vercel CLI 설치 확인
|
||||||
try {
|
try {
|
||||||
execSync('vercel --version', { stdio: 'ignore' });
|
execSync("vercel --version", { stdio: "ignore" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Vercel CLI가 설치되지 않았습니다.');
|
console.error("❌ Vercel CLI가 설치되지 않았습니다.");
|
||||||
console.error('다음 명령어로 설치해주세요: npm i -g vercel');
|
console.error("다음 명령어로 설치해주세요: npm i -g vercel");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// .env.example에서 환경 변수 파싱
|
// .env.example에서 환경 변수 파싱
|
||||||
console.log('📋 .env.example에서 환경 변수를 읽고 있습니다...');
|
console.log("📋 .env.example에서 환경 변수를 읽고 있습니다...");
|
||||||
const envVars = parseEnvFile(ENV_EXAMPLE_PATH);
|
const envVars = parseEnvFile(ENV_EXAMPLE_PATH);
|
||||||
|
|
||||||
if (Object.keys(envVars).length === 0) {
|
if (Object.keys(envVars).length === 0) {
|
||||||
console.log('⚠️ VITE_ 접두사를 가진 환경 변수가 없습니다.');
|
console.log("⚠️ VITE_ 접두사를 가진 환경 변수가 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔧 다음 환경 변수들을 Vercel에 설정해야 합니다:');
|
console.log("🔧 다음 환경 변수들을 Vercel에 설정해야 합니다:");
|
||||||
Object.keys(envVars).forEach(key => {
|
Object.keys(envVars).forEach((key) => {
|
||||||
console.log(` - ${key}`);
|
console.log(` - ${key}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\\n📝 Vercel 대시보드에서 수동으로 설정하거나,');
|
console.log("\\n📝 Vercel 대시보드에서 수동으로 설정하거나,");
|
||||||
console.log('다음 Vercel CLI 명령어들을 사용하세요:\\n');
|
console.log("다음 Vercel CLI 명령어들을 사용하세요:\\n");
|
||||||
|
|
||||||
// 환경별 설정 명령어 생성
|
// 환경별 설정 명령어 생성
|
||||||
const environments = [
|
const environments = [
|
||||||
{ name: 'production', flag: '--prod' },
|
{ name: "production", flag: "--prod" },
|
||||||
{ name: 'preview', flag: '--preview' },
|
{ name: "preview", flag: "--preview" },
|
||||||
{ name: 'development', flag: '--dev' }
|
{ name: "development", flag: "--dev" },
|
||||||
];
|
];
|
||||||
|
|
||||||
environments.forEach(env => {
|
environments.forEach((env) => {
|
||||||
console.log(`# ${env.name.toUpperCase()} 환경:`);
|
console.log(`# ${env.name.toUpperCase()} 환경:`);
|
||||||
Object.keys(envVars).forEach(key => {
|
Object.keys(envVars).forEach((key) => {
|
||||||
const placeholder = `your-${env.name}-${key.toLowerCase().replace('vite_', '').replace(/_/g, '-')}`;
|
const placeholder = `your-${env.name}-${key.toLowerCase().replace("vite_", "").replace(/_/g, "-")}`;
|
||||||
console.log(`vercel env add ${key} ${env.flag} # 값: ${placeholder}`);
|
console.log(`vercel env add ${key} ${env.flag} # 값: ${placeholder}`);
|
||||||
});
|
});
|
||||||
console.log('');
|
console.log("");
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('💡 팁: Vercel 대시보드 (Settings > Environment Variables)에서');
|
console.log("💡 팁: Vercel 대시보드 (Settings > Environment Variables)에서");
|
||||||
console.log(' 더 쉽게 환경 변수를 관리할 수 있습니다.');
|
console.log(" 더 쉽게 환경 변수를 관리할 수 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 스크립트 실행
|
// 스크립트 실행
|
||||||
|
|||||||
13
src/App.tsx
13
src/App.tsx
@@ -29,7 +29,9 @@ const ProfileManagement = lazy(() => import("./pages/ProfileManagement"));
|
|||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||||
const PaymentMethods = lazy(() => import("./pages/PaymentMethods"));
|
const PaymentMethods = lazy(() => import("./pages/PaymentMethods"));
|
||||||
const HelpSupport = lazy(() => import("./pages/HelpSupport"));
|
const HelpSupport = lazy(() => import("./pages/HelpSupport"));
|
||||||
const SecurityPrivacySettings = lazy(() => import("./pages/SecurityPrivacySettings"));
|
const SecurityPrivacySettings = lazy(
|
||||||
|
() => import("./pages/SecurityPrivacySettings")
|
||||||
|
);
|
||||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings"));
|
const NotificationSettings = lazy(() => import("./pages/NotificationSettings"));
|
||||||
const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
|
const ForgotPassword = lazy(() => import("./pages/ForgotPassword"));
|
||||||
const AppwriteSettingsPage = lazy(() => import("./pages/AppwriteSettingsPage"));
|
const AppwriteSettingsPage = lazy(() => import("./pages/AppwriteSettingsPage"));
|
||||||
@@ -198,7 +200,9 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary fallback={<ErrorScreen error={error} retry={handleRetry} />}>
|
<ErrorBoundary
|
||||||
|
fallback={<ErrorScreen error={error} retry={handleRetry} />}
|
||||||
|
>
|
||||||
<BasicLayout>
|
<BasicLayout>
|
||||||
<Suspense fallback={<PageLoadingSpinner />}>
|
<Suspense fallback={<PageLoadingSpinner />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -232,10 +236,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 오프라인 상태 관리 */}
|
{/* 오프라인 상태 관리 */}
|
||||||
<OfflineManager
|
<OfflineManager showOfflineToast={true} autoSyncOnReconnect={true} />
|
||||||
showOfflineToast={true}
|
|
||||||
autoSyncOnReconnect={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 백그라운드 자동 동기화 - 성능 최적화로 30초 간격으로 조정 */}
|
{/* 백그라운드 자동 동기화 - 성능 최적화로 30초 간격으로 조정 */}
|
||||||
<BackgroundSync
|
<BackgroundSync
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
describe('Button Component', () => {
|
describe("Button Component", () => {
|
||||||
it('renders button with text', () => {
|
it("renders button with text", () => {
|
||||||
render(<Button>Test Button</Button>);
|
render(<Button>Test Button</Button>);
|
||||||
expect(screen.getByRole('button', { name: 'Test Button' })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Test Button" })
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles click events', () => {
|
it("handles click events", () => {
|
||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
|
|
||||||
render(<Button onClick={handleClick}>Click me</Button>);
|
render(<Button onClick={handleClick}>Click me</Button>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
|
fireEvent.click(screen.getByRole("button", { name: "Click me" }));
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can be disabled', () => {
|
it("can be disabled", () => {
|
||||||
render(<Button disabled>Disabled Button</Button>);
|
render(<Button disabled>Disabled Button</Button>);
|
||||||
expect(screen.getByRole('button', { name: 'Disabled Button' })).toBeDisabled();
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Disabled Button" })
|
||||||
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies variant styles correctly', () => {
|
it("applies variant styles correctly", () => {
|
||||||
render(<Button variant="destructive">Delete</Button>);
|
render(<Button variant="destructive">Delete</Button>);
|
||||||
const button = screen.getByRole('button', { name: 'Delete' });
|
const button = screen.getByRole("button", { name: "Delete" });
|
||||||
expect(button).toHaveClass('bg-destructive');
|
expect(button).toHaveClass("bg-destructive");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import ExpenseForm from '../expenses/ExpenseForm';
|
import ExpenseForm from "../expenses/ExpenseForm";
|
||||||
|
|
||||||
// Mock child components with proper props handling
|
// Mock child components with proper props handling
|
||||||
vi.mock('../expenses/ExpenseFormFields', () => ({
|
vi.mock("../expenses/ExpenseFormFields", () => ({
|
||||||
default: ({ form, isSubmitting }: any) => (
|
default: ({ form, isSubmitting }: any) => (
|
||||||
<div data-testid="expense-form-fields">
|
<div data-testid="expense-form-fields">
|
||||||
<span data-testid="fields-submitting-state">{isSubmitting.toString()}</span>
|
<span data-testid="fields-submitting-state">
|
||||||
<span data-testid="form-object">{form ? 'form-present' : 'form-missing'}</span>
|
{isSubmitting.toString()}
|
||||||
|
</span>
|
||||||
|
<span data-testid="form-object">
|
||||||
|
{form ? "form-present" : "form-missing"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../expenses/ExpenseSubmitActions', () => ({
|
vi.mock("../expenses/ExpenseSubmitActions", () => ({
|
||||||
default: ({ onCancel, isSubmitting }: any) => (
|
default: ({ onCancel, isSubmitting }: any) => (
|
||||||
<div data-testid="expense-submit-actions">
|
<div data-testid="expense-submit-actions">
|
||||||
<button
|
<button
|
||||||
@@ -23,18 +27,14 @@ vi.mock('../expenses/ExpenseSubmitActions', () => ({
|
|||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" disabled={isSubmitting} data-testid="submit-button">
|
||||||
type="submit"
|
{isSubmitting ? "저장 중..." : "저장"}
|
||||||
disabled={isSubmitting}
|
|
||||||
data-testid="submit-button"
|
|
||||||
>
|
|
||||||
{isSubmitting ? '저장 중...' : '저장'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ExpenseForm', () => {
|
describe("ExpenseForm", () => {
|
||||||
const mockOnSubmit = vi.fn();
|
const mockOnSubmit = vi.fn();
|
||||||
const mockOnCancel = vi.fn();
|
const mockOnCancel = vi.fn();
|
||||||
|
|
||||||
@@ -48,73 +48,85 @@ describe('ExpenseForm', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe("rendering", () => {
|
||||||
it('renders the form with all child components', () => {
|
it("renders the form with all child components", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('expense-form')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('expense-form-fields')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form-fields")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('expense-submit-actions')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-submit-actions")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies correct CSS classes to form', () => {
|
it("applies correct CSS classes to form", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
const form = screen.getByTestId('expense-form');
|
const form = screen.getByTestId("expense-form");
|
||||||
expect(form).toHaveClass('space-y-4');
|
expect(form).toHaveClass("space-y-4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes form object to ExpenseFormFields', () => {
|
it("passes form object to ExpenseFormFields", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('form-object')).toHaveTextContent('form-present');
|
expect(screen.getByTestId("form-object")).toHaveTextContent(
|
||||||
|
"form-present"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isSubmitting prop handling', () => {
|
describe("isSubmitting prop handling", () => {
|
||||||
it('passes isSubmitting=false to child components', () => {
|
it("passes isSubmitting=false to child components", () => {
|
||||||
render(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
render(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('fields-submitting-state')).toHaveTextContent('false');
|
expect(screen.getByTestId("fields-submitting-state")).toHaveTextContent(
|
||||||
expect(screen.getByTestId('submit-button')).toHaveTextContent('저장');
|
"false"
|
||||||
expect(screen.getByTestId('submit-button')).not.toBeDisabled();
|
);
|
||||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
expect(screen.getByTestId("submit-button")).toHaveTextContent("저장");
|
||||||
|
expect(screen.getByTestId("submit-button")).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId("cancel-button")).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes isSubmitting=true to child components', () => {
|
it("passes isSubmitting=true to child components", () => {
|
||||||
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('fields-submitting-state')).toHaveTextContent('true');
|
expect(screen.getByTestId("fields-submitting-state")).toHaveTextContent(
|
||||||
expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
|
"true"
|
||||||
expect(screen.getByTestId('submit-button')).toBeDisabled();
|
);
|
||||||
expect(screen.getByTestId('cancel-button')).toBeDisabled();
|
expect(screen.getByTestId("submit-button")).toHaveTextContent(
|
||||||
|
"저장 중..."
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("submit-button")).toBeDisabled();
|
||||||
|
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates submitting state correctly when prop changes', () => {
|
it("updates submitting state correctly when prop changes", () => {
|
||||||
const { rerender } = render(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
const { rerender } = render(
|
||||||
|
<ExpenseForm {...defaultProps} isSubmitting={false} />
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('submit-button')).toHaveTextContent('저장');
|
expect(screen.getByTestId("submit-button")).toHaveTextContent("저장");
|
||||||
|
|
||||||
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
|
expect(screen.getByTestId("submit-button")).toHaveTextContent(
|
||||||
|
"저장 중..."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('form interactions', () => {
|
describe("form interactions", () => {
|
||||||
it('calls onCancel when cancel button is clicked', () => {
|
it("calls onCancel when cancel button is clicked", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('cancel-button'));
|
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||||
|
|
||||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not call onCancel when cancel button is disabled', () => {
|
it("does not call onCancel when cancel button is disabled", () => {
|
||||||
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
const cancelButton = screen.getByTestId('cancel-button');
|
const cancelButton = screen.getByTestId("cancel-button");
|
||||||
expect(cancelButton).toBeDisabled();
|
expect(cancelButton).toBeDisabled();
|
||||||
|
|
||||||
fireEvent.click(cancelButton);
|
fireEvent.click(cancelButton);
|
||||||
@@ -122,10 +134,10 @@ describe('ExpenseForm', () => {
|
|||||||
expect(mockOnCancel).not.toHaveBeenCalled();
|
expect(mockOnCancel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents form submission when submit button is disabled', () => {
|
it("prevents form submission when submit button is disabled", () => {
|
||||||
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
render(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submit-button');
|
const submitButton = screen.getByTestId("submit-button");
|
||||||
expect(submitButton).toBeDisabled();
|
expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
@@ -134,71 +146,81 @@ describe('ExpenseForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('prop validation', () => {
|
describe("prop validation", () => {
|
||||||
it('handles different onCancel functions correctly', () => {
|
it("handles different onCancel functions correctly", () => {
|
||||||
const customOnCancel = vi.fn();
|
const customOnCancel = vi.fn();
|
||||||
render(<ExpenseForm {...defaultProps} onCancel={customOnCancel} />);
|
render(<ExpenseForm {...defaultProps} onCancel={customOnCancel} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('cancel-button'));
|
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||||
|
|
||||||
expect(customOnCancel).toHaveBeenCalledTimes(1);
|
expect(customOnCancel).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnCancel).not.toHaveBeenCalled();
|
expect(mockOnCancel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maintains form structure with different prop combinations', () => {
|
it("maintains form structure with different prop combinations", () => {
|
||||||
const { rerender } = render(<ExpenseForm {...defaultProps} />);
|
const { rerender } = render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('expense-form')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(<ExpenseForm onSubmit={vi.fn()} onCancel={vi.fn()} isSubmitting={true} />);
|
rerender(
|
||||||
|
<ExpenseForm
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
isSubmitting={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('expense-form')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('expense-form-fields')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form-fields")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('expense-submit-actions')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-submit-actions")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('accessibility', () => {
|
describe("accessibility", () => {
|
||||||
it('maintains proper form semantics', () => {
|
it("maintains proper form semantics", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
const form = screen.getByTestId('expense-form');
|
const form = screen.getByTestId("expense-form");
|
||||||
expect(form.tagName).toBe('FORM');
|
expect(form.tagName).toBe("FORM");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submit button has correct type attribute', () => {
|
it("submit button has correct type attribute", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submit-button');
|
const submitButton = screen.getByTestId("submit-button");
|
||||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
expect(submitButton).toHaveAttribute("type", "submit");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancel button has correct type attribute', () => {
|
it("cancel button has correct type attribute", () => {
|
||||||
render(<ExpenseForm {...defaultProps} />);
|
render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
const cancelButton = screen.getByTestId('cancel-button');
|
const cancelButton = screen.getByTestId("cancel-button");
|
||||||
expect(cancelButton).toHaveAttribute('type', 'button');
|
expect(cancelButton).toHaveAttribute("type", "button");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe("edge cases", () => {
|
||||||
it('handles rapid state changes', () => {
|
it("handles rapid state changes", () => {
|
||||||
const { rerender } = render(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
const { rerender } = render(
|
||||||
|
<ExpenseForm {...defaultProps} isSubmitting={false} />
|
||||||
|
);
|
||||||
|
|
||||||
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
rerender(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
rerender(<ExpenseForm {...defaultProps} isSubmitting={false} />);
|
||||||
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('expense-form')).toBeInTheDocument();
|
expect(screen.getByTestId("expense-form")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('submit-button')).toHaveTextContent('저장 중...');
|
expect(screen.getByTestId("submit-button")).toHaveTextContent(
|
||||||
|
"저장 중..."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maintains component stability during prop updates', () => {
|
it("maintains component stability during prop updates", () => {
|
||||||
const { rerender } = render(<ExpenseForm {...defaultProps} />);
|
const { rerender } = render(<ExpenseForm {...defaultProps} />);
|
||||||
|
|
||||||
const form = screen.getByTestId('expense-form');
|
const form = screen.getByTestId("expense-form");
|
||||||
const formFields = screen.getByTestId('expense-form-fields');
|
const formFields = screen.getByTestId("expense-form-fields");
|
||||||
const submitActions = screen.getByTestId('expense-submit-actions');
|
const submitActions = screen.getByTestId("expense-submit-actions");
|
||||||
|
|
||||||
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
rerender(<ExpenseForm {...defaultProps} isSubmitting={true} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import Header from '../Header';
|
import Header from "../Header";
|
||||||
|
|
||||||
// 모든 의존성을 간단한 구현으로 모킹
|
// 모든 의존성을 간단한 구현으로 모킹
|
||||||
vi.mock('@/utils/logger', () => ({
|
vi.mock("@/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -11,19 +11,19 @@ vi.mock('@/utils/logger', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/stores', () => ({
|
vi.mock("@/stores", () => ({
|
||||||
useAuth: vi.fn(),
|
useAuth: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/hooks/use-mobile', () => ({
|
vi.mock("@/hooks/use-mobile", () => ({
|
||||||
useIsMobile: vi.fn(() => false),
|
useIsMobile: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/utils/platform', () => ({
|
vi.mock("@/utils/platform", () => ({
|
||||||
isIOSPlatform: vi.fn(() => false),
|
isIOSPlatform: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/hooks/useNotifications', () => ({
|
vi.mock("@/hooks/useNotifications", () => ({
|
||||||
default: vi.fn(() => ({
|
default: vi.fn(() => ({
|
||||||
notifications: [],
|
notifications: [],
|
||||||
clearAllNotifications: vi.fn(),
|
clearAllNotifications: vi.fn(),
|
||||||
@@ -31,13 +31,15 @@ vi.mock('@/hooks/useNotifications', () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../notification/NotificationPopover', () => ({
|
vi.mock("../notification/NotificationPopover", () => ({
|
||||||
default: () => <div data-testid="notification-popover">알림</div>
|
default: () => <div data-testid="notification-popover">알림</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/avatar', () => ({
|
vi.mock("@/components/ui/avatar", () => ({
|
||||||
Avatar: ({ children, className }: any) => (
|
Avatar: ({ children, className }: any) => (
|
||||||
<div data-testid="avatar" className={className}>{children}</div>
|
<div data-testid="avatar" className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
AvatarImage: ({ src, alt }: any) => (
|
AvatarImage: ({ src, alt }: any) => (
|
||||||
<img data-testid="avatar-image" src={src} alt={alt} />
|
<img data-testid="avatar-image" src={src} alt={alt} />
|
||||||
@@ -47,15 +49,17 @@ vi.mock('@/components/ui/avatar', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/skeleton', () => ({
|
vi.mock("@/components/ui/skeleton", () => ({
|
||||||
Skeleton: ({ className }: any) => (
|
Skeleton: ({ className }: any) => (
|
||||||
<div data-testid="skeleton" className={className}>Loading...</div>
|
<div data-testid="skeleton" className={className}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { useAuth } from '@/stores';
|
import { useAuth } from "@/stores";
|
||||||
|
|
||||||
describe('Header', () => {
|
describe("Header", () => {
|
||||||
const mockUseAuth = vi.mocked(useAuth);
|
const mockUseAuth = vi.mocked(useAuth);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -65,7 +69,7 @@ describe('Header', () => {
|
|||||||
global.Image = class {
|
global.Image = class {
|
||||||
onload: (() => void) | null = null;
|
onload: (() => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
src: string = '';
|
src: string = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -75,154 +79,154 @@ describe('Header', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('기본 렌더링', () => {
|
describe("기본 렌더링", () => {
|
||||||
it('헤더가 올바르게 렌더링된다', () => {
|
it("헤더가 올바르게 렌더링된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByTestId('header')).toBeInTheDocument();
|
expect(screen.getByTestId("header")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('avatar')).toBeInTheDocument();
|
expect(screen.getByTestId("avatar")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('notification-popover')).toBeInTheDocument();
|
expect(screen.getByTestId("notification-popover")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('로그인하지 않은 사용자에게 기본 인사말을 표시한다', () => {
|
it("로그인하지 않은 사용자에게 기본 인사말을 표시한다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("반갑습니다")).toBeInTheDocument();
|
||||||
expect(screen.getByText('젤리의 적자탈출')).toBeInTheDocument();
|
expect(screen.getByText("젤리의 적자탈출")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('로그인한 사용자에게 개인화된 인사말을 표시한다', () => {
|
it("로그인한 사용자에게 개인화된 인사말을 표시한다", () => {
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {
|
user: {
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
username: '김철수'
|
username: "김철수",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('김철수님, 반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("김철수님, 반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('사용자 이름이 없을 때 "익명"으로 표시한다', () => {
|
it('사용자 이름이 없을 때 "익명"으로 표시한다', () => {
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {
|
user: {
|
||||||
user_metadata: {}
|
user_metadata: {},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("익명님, 반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user_metadata가 없을 때 "익명"으로 표시한다', () => {
|
it('user_metadata가 없을 때 "익명"으로 표시한다', () => {
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {}
|
user: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("익명님, 반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CSS 클래스 및 스타일링', () => {
|
describe("CSS 클래스 및 스타일링", () => {
|
||||||
it('기본 헤더 클래스가 적용된다', () => {
|
it("기본 헤더 클래스가 적용된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const header = screen.getByTestId('header');
|
const header = screen.getByTestId("header");
|
||||||
expect(header).toHaveClass('py-4');
|
expect(header).toHaveClass("py-4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('아바타에 올바른 클래스가 적용된다', () => {
|
it("아바타에 올바른 클래스가 적용된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const avatar = screen.getByTestId('avatar');
|
const avatar = screen.getByTestId("avatar");
|
||||||
expect(avatar).toHaveClass('h-12', 'w-12', 'mr-3');
|
expect(avatar).toHaveClass("h-12", "w-12", "mr-3");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('제목에 올바른 스타일이 적용된다', () => {
|
it("제목에 올바른 스타일이 적용된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const title = screen.getByText('반갑습니다');
|
const title = screen.getByText("반갑습니다");
|
||||||
expect(title).toHaveClass('font-bold', 'neuro-text', 'text-xl');
|
expect(title).toHaveClass("font-bold", "neuro-text", "text-xl");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('부제목에 올바른 스타일이 적용된다', () => {
|
it("부제목에 올바른 스타일이 적용된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const subtitle = screen.getByText('젤리의 적자탈출');
|
const subtitle = screen.getByText("젤리의 적자탈출");
|
||||||
expect(subtitle).toHaveClass('text-gray-500', 'text-left');
|
expect(subtitle).toHaveClass("text-gray-500", "text-left");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('아바타 처리', () => {
|
describe("아바타 처리", () => {
|
||||||
it('아바타 컨테이너가 있다', () => {
|
it("아바타 컨테이너가 있다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByTestId('avatar')).toBeInTheDocument();
|
expect(screen.getByTestId("avatar")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('이미지 로딩 중에 스켈레톤을 표시한다', () => {
|
it("이미지 로딩 중에 스켈레톤을 표시한다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
expect(screen.getByTestId("skeleton")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('알림 시스템', () => {
|
describe("알림 시스템", () => {
|
||||||
it('알림 팝오버가 렌더링된다', () => {
|
it("알림 팝오버가 렌더링된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByTestId('notification-popover')).toBeInTheDocument();
|
expect(screen.getByTestId("notification-popover")).toBeInTheDocument();
|
||||||
expect(screen.getByText('알림')).toBeInTheDocument();
|
expect(screen.getByText("알림")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('접근성', () => {
|
describe("접근성", () => {
|
||||||
it('헤더가 올바른 시맨틱 태그를 사용한다', () => {
|
it("헤더가 올바른 시맨틱 태그를 사용한다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const header = screen.getByTestId('header');
|
const header = screen.getByTestId("header");
|
||||||
expect(header.tagName).toBe('HEADER');
|
expect(header.tagName).toBe("HEADER");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('제목이 h1 태그로 렌더링된다', () => {
|
it("제목이 h1 태그로 렌더링된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const title = screen.getByRole('heading', { level: 1 });
|
const title = screen.getByRole("heading", { level: 1 });
|
||||||
expect(title).toBeInTheDocument();
|
expect(title).toBeInTheDocument();
|
||||||
expect(title).toHaveTextContent('반갑습니다');
|
expect(title).toHaveTextContent("반갑습니다");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('엣지 케이스', () => {
|
describe("엣지 케이스", () => {
|
||||||
it('user가 null일 때 크래시하지 않는다', () => {
|
it("user가 null일 때 크래시하지 않는다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
@@ -230,91 +234,97 @@ describe('Header', () => {
|
|||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user_metadata가 없어도 처리한다', () => {
|
it("user_metadata가 없어도 처리한다", () => {
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {}
|
user: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("익명님, 반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('긴 사용자 이름을 처리한다', () => {
|
it("긴 사용자 이름을 처리한다", () => {
|
||||||
const longUsername = 'VeryLongUserNameThatMightCauseIssues';
|
const longUsername = "VeryLongUserNameThatMightCauseIssues";
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {
|
user: {
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
username: longUsername
|
username: longUsername,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText(`${longUsername}님, 반갑습니다`)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(`${longUsername}님, 반갑습니다`)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('특수 문자가 포함된 사용자 이름을 처리한다', () => {
|
it("특수 문자가 포함된 사용자 이름을 처리한다", () => {
|
||||||
const specialUsername = '김@철#수$123';
|
const specialUsername = "김@철#수$123";
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {
|
user: {
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
username: specialUsername
|
username: specialUsername,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText(`${specialUsername}님, 반갑습니다`)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(`${specialUsername}님, 반갑습니다`)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('빈 문자열 사용자 이름을 처리한다', () => {
|
it("빈 문자열 사용자 이름을 처리한다", () => {
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
user: {
|
user: {
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
username: ''
|
username: "",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('익명님, 반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("익명님, 반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('undefined user를 처리한다', () => {
|
it("undefined user를 처리한다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: undefined });
|
mockUseAuth.mockReturnValue({ user: undefined });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
expect(screen.getByText('반갑습니다')).toBeInTheDocument();
|
expect(screen.getByText("반갑습니다")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('레이아웃 및 구조', () => {
|
describe("레이아웃 및 구조", () => {
|
||||||
it('올바른 레이아웃 구조를 가진다', () => {
|
it("올바른 레이아웃 구조를 가진다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const header = screen.getByTestId('header');
|
const header = screen.getByTestId("header");
|
||||||
const flexContainer = header.querySelector('.flex.justify-between.items-center');
|
const flexContainer = header.querySelector(
|
||||||
|
".flex.justify-between.items-center"
|
||||||
|
);
|
||||||
expect(flexContainer).toBeInTheDocument();
|
expect(flexContainer).toBeInTheDocument();
|
||||||
|
|
||||||
const leftSection = flexContainer?.querySelector('.flex.items-center');
|
const leftSection = flexContainer?.querySelector(".flex.items-center");
|
||||||
expect(leftSection).toBeInTheDocument();
|
expect(leftSection).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('아바타와 텍스트가 올바르게 배치된다', () => {
|
it("아바타와 텍스트가 올바르게 배치된다", () => {
|
||||||
mockUseAuth.mockReturnValue({ user: null });
|
mockUseAuth.mockReturnValue({ user: null });
|
||||||
|
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|
||||||
const avatar = screen.getByTestId('avatar');
|
const avatar = screen.getByTestId("avatar");
|
||||||
const title = screen.getByText('반갑습니다');
|
const title = screen.getByText("반갑습니다");
|
||||||
const subtitle = screen.getByText('젤리의 적자탈출');
|
const subtitle = screen.getByText("젤리의 적자탈출");
|
||||||
|
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(avatar).toBeInTheDocument();
|
||||||
expect(title).toBeInTheDocument();
|
expect(title).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import LoginForm from '../auth/LoginForm';
|
import LoginForm from "../auth/LoginForm";
|
||||||
|
|
||||||
// Mock react-router-dom Link component
|
// Mock react-router-dom Link component
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock("react-router-dom", async () => {
|
||||||
const actual = await vi.importActual('react-router-dom');
|
const actual = await vi.importActual("react-router-dom");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Link: ({ to, children, className }: any) => (
|
Link: ({ to, children, className }: any) => (
|
||||||
@@ -21,16 +21,16 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|||||||
<BrowserRouter>{children}</BrowserRouter>
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('LoginForm', () => {
|
describe("LoginForm", () => {
|
||||||
const mockSetEmail = vi.fn();
|
const mockSetEmail = vi.fn();
|
||||||
const mockSetPassword = vi.fn();
|
const mockSetPassword = vi.fn();
|
||||||
const mockSetShowPassword = vi.fn();
|
const mockSetShowPassword = vi.fn();
|
||||||
const mockHandleLogin = vi.fn();
|
const mockHandleLogin = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
email: '',
|
email: "",
|
||||||
setEmail: mockSetEmail,
|
setEmail: mockSetEmail,
|
||||||
password: '',
|
password: "",
|
||||||
setPassword: mockSetPassword,
|
setPassword: mockSetPassword,
|
||||||
showPassword: false,
|
showPassword: false,
|
||||||
setShowPassword: mockSetShowPassword,
|
setShowPassword: mockSetShowPassword,
|
||||||
@@ -44,121 +44,121 @@ describe('LoginForm', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe("rendering", () => {
|
||||||
it('renders the login form with all fields', () => {
|
it("renders the login form with all fields", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('이메일')).toBeInTheDocument();
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
||||||
expect(screen.getByText('로그인')).toBeInTheDocument();
|
expect(screen.getByText("로그인")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('forgot-password-link')).toBeInTheDocument();
|
expect(screen.getByTestId("forgot-password-link")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders email field with correct attributes', () => {
|
it("renders email field with correct attributes", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('이메일');
|
const emailInput = screen.getByLabelText("이메일");
|
||||||
expect(emailInput).toHaveAttribute('type', 'email');
|
expect(emailInput).toHaveAttribute("type", "email");
|
||||||
expect(emailInput).toHaveAttribute('id', 'email');
|
expect(emailInput).toHaveAttribute("id", "email");
|
||||||
expect(emailInput).toHaveAttribute('placeholder', 'your@email.com');
|
expect(emailInput).toHaveAttribute("placeholder", "your@email.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders password field with correct attributes', () => {
|
it("renders password field with correct attributes", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
expect(passwordInput).toHaveAttribute("type", "password");
|
||||||
expect(passwordInput).toHaveAttribute('id', 'password');
|
expect(passwordInput).toHaveAttribute("id", "password");
|
||||||
expect(passwordInput).toHaveAttribute('placeholder', '••••••••');
|
expect(passwordInput).toHaveAttribute("placeholder", "••••••••");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders forgot password link', () => {
|
it("renders forgot password link", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const forgotLink = screen.getByTestId('forgot-password-link');
|
const forgotLink = screen.getByTestId("forgot-password-link");
|
||||||
expect(forgotLink).toHaveAttribute('href', '/forgot-password');
|
expect(forgotLink).toHaveAttribute("href", "/forgot-password");
|
||||||
expect(forgotLink).toHaveTextContent('비밀번호를 잊으셨나요?');
|
expect(forgotLink).toHaveTextContent("비밀번호를 잊으셨나요?");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('form values', () => {
|
describe("form values", () => {
|
||||||
it('displays current email value', () => {
|
it("displays current email value", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} email="test@example.com" />, {
|
||||||
<LoginForm {...defaultProps} email="test@example.com" />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays current password value', () => {
|
expect(screen.getByDisplayValue("test@example.com")).toBeInTheDocument();
|
||||||
render(
|
|
||||||
<LoginForm {...defaultProps} password="mypassword" />,
|
|
||||||
{ wrapper: Wrapper }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('mypassword')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setEmail when email input changes', () => {
|
it("displays current password value", () => {
|
||||||
|
render(<LoginForm {...defaultProps} password="mypassword" />, {
|
||||||
|
wrapper: Wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("mypassword")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setEmail when email input changes", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('이메일');
|
const emailInput = screen.getByLabelText("이메일");
|
||||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
|
||||||
expect(mockSetEmail).toHaveBeenCalledWith('test@example.com');
|
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setPassword when password input changes', () => {
|
it("calls setPassword when password input changes", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
fireEvent.change(passwordInput, { target: { value: 'newpassword' } });
|
fireEvent.change(passwordInput, { target: { value: "newpassword" } });
|
||||||
|
|
||||||
expect(mockSetPassword).toHaveBeenCalledWith('newpassword');
|
expect(mockSetPassword).toHaveBeenCalledWith("newpassword");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('password visibility toggle', () => {
|
describe("password visibility toggle", () => {
|
||||||
it('shows password as hidden by default', () => {
|
it("shows password as hidden by default", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
expect(passwordInput).toHaveAttribute("type", "password");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows password as text when showPassword is true', () => {
|
it("shows password as text when showPassword is true", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} showPassword={true} />, {
|
||||||
<LoginForm {...defaultProps} showPassword={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
|
||||||
);
|
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
|
||||||
expect(passwordInput).toHaveAttribute('type', 'text');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setShowPassword when visibility toggle is clicked', () => {
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
|
expect(passwordInput).toHaveAttribute("type", "text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setShowPassword when visibility toggle is clicked", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
// Find the password toggle button (the one that's not the submit button)
|
// Find the password toggle button (the one that's not the submit button)
|
||||||
const buttons = screen.getAllByRole('button');
|
const buttons = screen.getAllByRole("button");
|
||||||
const toggleButton = buttons.find(btn => btn.getAttribute('type') === 'button');
|
const toggleButton = buttons.find(
|
||||||
|
(btn) => btn.getAttribute("type") === "button"
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.click(toggleButton!);
|
fireEvent.click(toggleButton!);
|
||||||
|
|
||||||
expect(mockSetShowPassword).toHaveBeenCalledWith(true);
|
expect(mockSetShowPassword).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setShowPassword with opposite value', () => {
|
it("calls setShowPassword with opposite value", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} showPassword={true} />, {
|
||||||
<LoginForm {...defaultProps} showPassword={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Find the password toggle button (the one that's not the submit button)
|
// Find the password toggle button (the one that's not the submit button)
|
||||||
const buttons = screen.getAllByRole('button');
|
const buttons = screen.getAllByRole("button");
|
||||||
const toggleButton = buttons.find(btn => btn.getAttribute('type') === 'button');
|
const toggleButton = buttons.find(
|
||||||
|
(btn) => btn.getAttribute("type") === "button"
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.click(toggleButton!);
|
fireEvent.click(toggleButton!);
|
||||||
|
|
||||||
@@ -166,245 +166,258 @@ describe('LoginForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('form submission', () => {
|
describe("form submission", () => {
|
||||||
it('calls handleLogin when form is submitted', () => {
|
it("calls handleLogin when form is submitted", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const form = screen.getByTestId('login-form');
|
const form = screen.getByTestId("login-form");
|
||||||
fireEvent.submit(form);
|
fireEvent.submit(form);
|
||||||
|
|
||||||
expect(mockHandleLogin).toHaveBeenCalledTimes(1);
|
expect(mockHandleLogin).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not submit when form is disabled during loading', () => {
|
it("does not submit when form is disabled during loading", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} isLoading={true} />, {
|
||||||
<LoginForm {...defaultProps} isLoading={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const loginButton = screen.getByText('로그인 중...');
|
const loginButton = screen.getByText("로그인 중...");
|
||||||
expect(loginButton).toBeDisabled();
|
expect(loginButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not submit when form is disabled during table setup', () => {
|
it("does not submit when form is disabled during table setup", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} isSettingUpTables={true} />, {
|
||||||
<LoginForm {...defaultProps} isSettingUpTables={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const loginButton = screen.getByText('데이터베이스 설정 중...');
|
const loginButton = screen.getByText("데이터베이스 설정 중...");
|
||||||
expect(loginButton).toBeDisabled();
|
expect(loginButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loading states', () => {
|
describe("loading states", () => {
|
||||||
it('shows loading text when isLoading is true', () => {
|
it("shows loading text when isLoading is true", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} isLoading={true} />, {
|
||||||
<LoginForm {...defaultProps} isLoading={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('로그인 중...')).toBeInTheDocument();
|
expect(screen.getByText("로그인 중...")).toBeInTheDocument();
|
||||||
const submitButton = screen.getByText('로그인 중...').closest('button');
|
const submitButton = screen.getByText("로그인 중...").closest("button");
|
||||||
expect(submitButton).toBeDisabled();
|
expect(submitButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows table setup text when isSettingUpTables is true', () => {
|
it("shows table setup text when isSettingUpTables is true", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} isSettingUpTables={true} />, {
|
||||||
<LoginForm {...defaultProps} isSettingUpTables={true} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('데이터베이스 설정 중...')).toBeInTheDocument();
|
expect(screen.getByText("데이터베이스 설정 중...")).toBeInTheDocument();
|
||||||
const submitButton = screen.getByText('데이터베이스 설정 중...').closest('button');
|
const submitButton = screen
|
||||||
|
.getByText("데이터베이스 설정 중...")
|
||||||
|
.closest("button");
|
||||||
expect(submitButton).toBeDisabled();
|
expect(submitButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows normal text when not loading', () => {
|
it("shows normal text when not loading", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
expect(screen.getByText('로그인')).toBeInTheDocument();
|
expect(screen.getByText("로그인")).toBeInTheDocument();
|
||||||
const submitButton = screen.getByText('로그인').closest('button');
|
const submitButton = screen.getByText("로그인").closest("button");
|
||||||
expect(submitButton).not.toBeDisabled();
|
expect(submitButton).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isLoading takes precedence over isSettingUpTables', () => {
|
it("isLoading takes precedence over isSettingUpTables", () => {
|
||||||
render(
|
render(
|
||||||
<LoginForm {...defaultProps} isLoading={true} isSettingUpTables={true} />,
|
<LoginForm
|
||||||
|
{...defaultProps}
|
||||||
|
isLoading={true}
|
||||||
|
isSettingUpTables={true}
|
||||||
|
/>,
|
||||||
{ wrapper: Wrapper }
|
{ wrapper: Wrapper }
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('로그인 중...')).toBeInTheDocument();
|
expect(screen.getByText("로그인 중...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe("error handling", () => {
|
||||||
it('does not show error message when loginError is null', () => {
|
it("does not show error message when loginError is null", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
expect(screen.queryByText(/에러/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/에러/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows regular error message for standard errors', () => {
|
it("shows regular error message for standard errors", () => {
|
||||||
const errorMessage = '잘못된 이메일 또는 비밀번호입니다.';
|
const errorMessage = "잘못된 이메일 또는 비밀번호입니다.";
|
||||||
render(
|
render(<LoginForm {...defaultProps} loginError={errorMessage} />, {
|
||||||
<LoginForm {...defaultProps} loginError={errorMessage} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows CORS/JSON error with special styling and suggestions', () => {
|
it("shows CORS/JSON error with special styling and suggestions", () => {
|
||||||
const corsError = 'CORS 정책에 의해 차단되었습니다.';
|
const corsError = "CORS 정책에 의해 차단되었습니다.";
|
||||||
render(
|
render(<LoginForm {...defaultProps} loginError={corsError} />, {
|
||||||
<LoginForm {...defaultProps} loginError={corsError} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(corsError)).toBeInTheDocument();
|
expect(screen.getByText(corsError)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByText(/HTTPS URL을 사용하는 Supabase 인스턴스로 변경/)).toBeInTheDocument();
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
||||||
expect(screen.getByText(/네트워크 연결 상태를 확인하세요/)).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/HTTPS URL을 사용하는 Supabase 인스턴스로 변경/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/네트워크 연결 상태를 확인하세요/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects JSON errors correctly', () => {
|
it("detects JSON errors correctly", () => {
|
||||||
const jsonError = 'JSON 파싱 오류가 발생했습니다.';
|
const jsonError = "JSON 파싱 오류가 발생했습니다.";
|
||||||
render(
|
render(<LoginForm {...defaultProps} loginError={jsonError} />, {
|
||||||
<LoginForm {...defaultProps} loginError={jsonError} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(jsonError)).toBeInTheDocument();
|
expect(screen.getByText(jsonError)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects network 404 errors correctly', () => {
|
it("detects network 404 errors correctly", () => {
|
||||||
const networkError = '404 Not Found 오류입니다.';
|
const networkError = "404 Not Found 오류입니다.";
|
||||||
render(
|
render(<LoginForm {...defaultProps} loginError={networkError} />, {
|
||||||
<LoginForm {...defaultProps} loginError={networkError} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(networkError)).toBeInTheDocument();
|
expect(screen.getByText(networkError)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/네트워크 연결 상태를 확인하세요/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/네트워크 연결 상태를 확인하세요/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects proxy errors correctly', () => {
|
it("detects proxy errors correctly", () => {
|
||||||
const proxyError = '프록시 서버 응답 오류입니다.';
|
const proxyError = "프록시 서버 응답 오류입니다.";
|
||||||
render(
|
render(<LoginForm {...defaultProps} loginError={proxyError} />, {
|
||||||
<LoginForm {...defaultProps} loginError={proxyError} />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(proxyError)).toBeInTheDocument();
|
expect(screen.getByText(proxyError)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(/다른 CORS 프록시 유형을 시도해 보세요/)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CSS classes and styling', () => {
|
describe("CSS classes and styling", () => {
|
||||||
it('applies correct CSS classes to form container', () => {
|
it("applies correct CSS classes to form container", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const container = screen.getByTestId('login-form').parentElement;
|
const container = screen.getByTestId("login-form").parentElement;
|
||||||
expect(container).toHaveClass('neuro-flat', 'p-8', 'mb-6');
|
expect(container).toHaveClass("neuro-flat", "p-8", "mb-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies correct CSS classes to email input', () => {
|
it("applies correct CSS classes to email input", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('이메일');
|
const emailInput = screen.getByLabelText("이메일");
|
||||||
expect(emailInput).toHaveClass('pl-10', 'neuro-pressed');
|
expect(emailInput).toHaveClass("pl-10", "neuro-pressed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies correct CSS classes to password input', () => {
|
it("applies correct CSS classes to password input", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
expect(passwordInput).toHaveClass('pl-10', 'neuro-pressed');
|
expect(passwordInput).toHaveClass("pl-10", "neuro-pressed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies correct CSS classes to submit button', () => {
|
it("applies correct CSS classes to submit button", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const submitButton = screen.getByRole('button', { name: /로그인/ });
|
const submitButton = screen.getByRole("button", { name: /로그인/ });
|
||||||
expect(submitButton).toHaveClass(
|
expect(submitButton).toHaveClass(
|
||||||
'w-full',
|
"w-full",
|
||||||
'hover:bg-neuro-income/80',
|
"hover:bg-neuro-income/80",
|
||||||
'text-white',
|
"text-white",
|
||||||
'h-auto',
|
"h-auto",
|
||||||
'bg-neuro-income',
|
"bg-neuro-income",
|
||||||
'text-lg'
|
"text-lg"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('accessibility', () => {
|
describe("accessibility", () => {
|
||||||
it('has proper form labels', () => {
|
it("has proper form labels", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
expect(screen.getByLabelText('이메일')).toBeInTheDocument();
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has proper input IDs matching labels', () => {
|
it("has proper input IDs matching labels", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('이메일');
|
const emailInput = screen.getByLabelText("이메일");
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
|
|
||||||
expect(emailInput).toHaveAttribute('id', 'email');
|
expect(emailInput).toHaveAttribute("id", "email");
|
||||||
expect(passwordInput).toHaveAttribute('id', 'password');
|
expect(passwordInput).toHaveAttribute("id", "password");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('password toggle button has correct type', () => {
|
it("password toggle button has correct type", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
// Find the eye icon button (the one that's not the submit button)
|
// Find the eye icon button (the one that's not the submit button)
|
||||||
const buttons = screen.getAllByRole('button');
|
const buttons = screen.getAllByRole("button");
|
||||||
const toggleButton = buttons.find(button => button.getAttribute('type') === 'button');
|
const toggleButton = buttons.find(
|
||||||
|
(button) => button.getAttribute("type") === "button"
|
||||||
|
);
|
||||||
|
|
||||||
expect(toggleButton).toHaveAttribute('type', 'button');
|
expect(toggleButton).toHaveAttribute("type", "button");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submit button has correct type', () => {
|
it("submit button has correct type", () => {
|
||||||
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
||||||
|
|
||||||
const submitButton = screen.getByText('로그인').closest('button');
|
const submitButton = screen.getByText("로그인").closest("button");
|
||||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
expect(submitButton).toHaveAttribute("type", "submit");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe("edge cases", () => {
|
||||||
it('handles empty email and password values', () => {
|
it("handles empty email and password values", () => {
|
||||||
render(
|
render(<LoginForm {...defaultProps} email="" password="" />, {
|
||||||
<LoginForm {...defaultProps} email="" password="" />,
|
wrapper: Wrapper,
|
||||||
{ wrapper: Wrapper }
|
|
||||||
);
|
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('이메일');
|
|
||||||
const passwordInput = screen.getByLabelText('비밀번호');
|
|
||||||
|
|
||||||
expect(emailInput).toHaveValue('');
|
|
||||||
expect(passwordInput).toHaveValue('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles very long error messages', () => {
|
const emailInput = screen.getByLabelText("이메일");
|
||||||
const longError = 'A'.repeat(1000);
|
const passwordInput = screen.getByLabelText("비밀번호");
|
||||||
render(
|
|
||||||
<LoginForm {...defaultProps} loginError={longError} />,
|
expect(emailInput).toHaveValue("");
|
||||||
{ wrapper: Wrapper }
|
expect(passwordInput).toHaveValue("");
|
||||||
);
|
});
|
||||||
|
|
||||||
|
it("handles very long error messages", () => {
|
||||||
|
const longError = "A".repeat(1000);
|
||||||
|
render(<LoginForm {...defaultProps} loginError={longError} />, {
|
||||||
|
wrapper: Wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText(longError)).toBeInTheDocument();
|
expect(screen.getByText(longError)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles special characters in email and password', () => {
|
it("handles special characters in email and password", () => {
|
||||||
const specialEmail = 'test+tag@example-domain.co.uk';
|
const specialEmail = "test+tag@example-domain.co.uk";
|
||||||
const specialPassword = 'P@ssw0rd!#$%';
|
const specialPassword = "P@ssw0rd!#$%";
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<LoginForm {...defaultProps} email={specialEmail} password={specialPassword} />,
|
<LoginForm
|
||||||
|
{...defaultProps}
|
||||||
|
email={specialEmail}
|
||||||
|
password={specialPassword}
|
||||||
|
/>,
|
||||||
{ wrapper: Wrapper }
|
{ wrapper: Wrapper }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -412,8 +425,10 @@ describe('LoginForm', () => {
|
|||||||
expect(screen.getByDisplayValue(specialPassword)).toBeInTheDocument();
|
expect(screen.getByDisplayValue(specialPassword)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maintains form functionality during rapid state changes', () => {
|
it("maintains form functionality during rapid state changes", () => {
|
||||||
const { rerender } = render(<LoginForm {...defaultProps} />, { wrapper: Wrapper });
|
const { rerender } = render(<LoginForm {...defaultProps} />, {
|
||||||
|
wrapper: Wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
// Rapid state changes
|
// Rapid state changes
|
||||||
rerender(<LoginForm {...defaultProps} isLoading={true} />);
|
rerender(<LoginForm {...defaultProps} isLoading={true} />);
|
||||||
@@ -422,9 +437,9 @@ describe('LoginForm', () => {
|
|||||||
rerender(<LoginForm {...defaultProps} />);
|
rerender(<LoginForm {...defaultProps} />);
|
||||||
|
|
||||||
// Form should still be functional
|
// Form should still be functional
|
||||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('이메일')).toBeInTheDocument();
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
|
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import TransactionCard from '../TransactionCard';
|
import TransactionCard from "../TransactionCard";
|
||||||
import { Transaction } from '@/contexts/budget/types';
|
import { Transaction } from "@/contexts/budget/types";
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from "@/utils/logger";
|
||||||
|
|
||||||
// Mock child components to isolate TransactionCard testing
|
// Mock child components to isolate TransactionCard testing
|
||||||
vi.mock('../TransactionEditDialog', () => ({
|
vi.mock("../TransactionEditDialog", () => ({
|
||||||
default: ({ open, onOpenChange, transaction, onDelete }: any) =>
|
default: ({ open, onOpenChange, transaction, onDelete }: any) =>
|
||||||
open ? (
|
open ? (
|
||||||
<div data-testid="transaction-edit-dialog">
|
<div data-testid="transaction-edit-dialog">
|
||||||
@@ -13,278 +13,310 @@ vi.mock('../TransactionEditDialog', () => ({
|
|||||||
<button onClick={() => onOpenChange(false)}>Close</button>
|
<button onClick={() => onOpenChange(false)}>Close</button>
|
||||||
<button onClick={() => onDelete(transaction.id)}>Delete</button>
|
<button onClick={() => onDelete(transaction.id)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../transaction/TransactionIcon', () => ({
|
vi.mock("../transaction/TransactionIcon", () => ({
|
||||||
default: ({ category }: { category: string }) => (
|
default: ({ category }: { category: string }) => (
|
||||||
<div data-testid="transaction-icon">{category} icon</div>
|
<div data-testid="transaction-icon">{category} icon</div>
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../transaction/TransactionDetails', () => ({
|
vi.mock("../transaction/TransactionDetails", () => ({
|
||||||
default: ({ title, date }: { title: string; date: string }) => (
|
default: ({ title, date }: { title: string; date: string }) => (
|
||||||
<div data-testid="transaction-details">
|
<div data-testid="transaction-details">
|
||||||
<div>{title}</div>
|
<div>{title}</div>
|
||||||
<div>{date}</div>
|
<div>{date}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../transaction/TransactionAmount', () => ({
|
vi.mock("../transaction/TransactionAmount", () => ({
|
||||||
default: ({ amount }: { amount: number }) => (
|
default: ({ amount }: { amount: number }) => (
|
||||||
<div data-testid="transaction-amount">{amount}원</div>
|
<div data-testid="transaction-amount">{amount}원</div>
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger
|
// Mock logger
|
||||||
vi.mock('@/utils/logger', () => ({
|
vi.mock("@/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('TransactionCard', () => {
|
describe("TransactionCard", () => {
|
||||||
const mockTransaction: Transaction = {
|
const mockTransaction: Transaction = {
|
||||||
id: 'test-transaction-1',
|
id: "test-transaction-1",
|
||||||
title: 'Coffee Shop',
|
title: "Coffee Shop",
|
||||||
amount: 5000,
|
amount: 5000,
|
||||||
date: '2024-06-15',
|
date: "2024-06-15",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
type: 'expense',
|
type: "expense",
|
||||||
paymentMethod: '신용카드',
|
paymentMethod: "신용카드",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe("rendering", () => {
|
||||||
it('renders transaction card with all components', () => {
|
it("renders transaction card with all components", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('transaction-icon')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-icon")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('transaction-details')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-details")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('transaction-amount')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-amount")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
|
expect(screen.getByText("Coffee Shop")).toBeInTheDocument();
|
||||||
expect(screen.getByText('2024-06-15')).toBeInTheDocument();
|
expect(screen.getByText("2024-06-15")).toBeInTheDocument();
|
||||||
expect(screen.getByText('5000원')).toBeInTheDocument();
|
expect(screen.getByText("5000원")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes correct props to child components', () => {
|
it("passes correct props to child components", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
expect(screen.getByText('Food icon')).toBeInTheDocument();
|
expect(screen.getByText("Food icon")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
|
expect(screen.getByText("Coffee Shop")).toBeInTheDocument();
|
||||||
expect(screen.getByText('2024-06-15')).toBeInTheDocument();
|
expect(screen.getByText("2024-06-15")).toBeInTheDocument();
|
||||||
expect(screen.getByText('5000원')).toBeInTheDocument();
|
expect(screen.getByText("5000원")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with different transaction data', () => {
|
it("renders with different transaction data", () => {
|
||||||
const differentTransaction: Transaction = {
|
const differentTransaction: Transaction = {
|
||||||
id: 'test-transaction-2',
|
id: "test-transaction-2",
|
||||||
title: 'Gas Station',
|
title: "Gas Station",
|
||||||
amount: 50000,
|
amount: 50000,
|
||||||
date: '2024-07-01',
|
date: "2024-07-01",
|
||||||
category: 'Transportation',
|
category: "Transportation",
|
||||||
type: 'expense',
|
type: "expense",
|
||||||
paymentMethod: '현금',
|
paymentMethod: "현금",
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TransactionCard transaction={differentTransaction} />);
|
render(<TransactionCard transaction={differentTransaction} />);
|
||||||
|
|
||||||
expect(screen.getByText('Gas Station')).toBeInTheDocument();
|
expect(screen.getByText("Gas Station")).toBeInTheDocument();
|
||||||
expect(screen.getByText('2024-07-01')).toBeInTheDocument();
|
expect(screen.getByText("2024-07-01")).toBeInTheDocument();
|
||||||
expect(screen.getByText('50000원')).toBeInTheDocument();
|
expect(screen.getByText("50000원")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Transportation icon')).toBeInTheDocument();
|
expect(screen.getByText("Transportation icon")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('user interactions', () => {
|
describe("user interactions", () => {
|
||||||
it('opens edit dialog when card is clicked', () => {
|
it("opens edit dialog when card is clicked", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
|
|
||||||
expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-edit-dialog")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Edit Dialog for: Coffee Shop')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("Edit Dialog for: Coffee Shop")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes edit dialog when close button is clicked', () => {
|
it("closes edit dialog when close button is clicked", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
// Open dialog
|
// Open dialog
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-edit-dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
// Close dialog
|
// Close dialog
|
||||||
const closeButton = screen.getByText('Close');
|
const closeButton = screen.getByText("Close");
|
||||||
fireEvent.click(closeButton);
|
fireEvent.click(closeButton);
|
||||||
expect(screen.queryByTestId('transaction-edit-dialog')).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByTestId("transaction-edit-dialog")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initially does not show edit dialog', () => {
|
it("initially does not show edit dialog", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
expect(screen.queryByTestId('transaction-edit-dialog')).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByTestId("transaction-edit-dialog")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete functionality', () => {
|
describe("delete functionality", () => {
|
||||||
it('calls onDelete when delete button is clicked in dialog', async () => {
|
it("calls onDelete when delete button is clicked in dialog", async () => {
|
||||||
const mockOnDelete = vi.fn().mockResolvedValue(true);
|
const mockOnDelete = vi.fn().mockResolvedValue(true);
|
||||||
render(<TransactionCard transaction={mockTransaction} onDelete={mockOnDelete} />);
|
render(
|
||||||
|
<TransactionCard
|
||||||
|
transaction={mockTransaction}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Open dialog
|
// Open dialog
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
|
|
||||||
// Click delete
|
// Click delete
|
||||||
const deleteButton = screen.getByText('Delete');
|
const deleteButton = screen.getByText("Delete");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(mockOnDelete).toHaveBeenCalledWith('test-transaction-1');
|
expect(mockOnDelete).toHaveBeenCalledWith("test-transaction-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles delete when no onDelete prop is provided', async () => {
|
it("handles delete when no onDelete prop is provided", async () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
// Open dialog
|
// Open dialog
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
|
|
||||||
// Click delete (should not crash)
|
// Click delete (should not crash)
|
||||||
const deleteButton = screen.getByText('Delete');
|
const deleteButton = screen.getByText("Delete");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
// Should not crash and should log
|
// Should not crash and should log
|
||||||
expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
|
expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
|
||||||
'삭제 핸들러가 제공되지 않았습니다'
|
"삭제 핸들러가 제공되지 않았습니다"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles delete error gracefully', async () => {
|
it("handles delete error gracefully", async () => {
|
||||||
const mockOnDelete = vi.fn().mockRejectedValue(new Error('Delete failed'));
|
const mockOnDelete = vi
|
||||||
render(<TransactionCard transaction={mockTransaction} onDelete={mockOnDelete} />);
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("Delete failed"));
|
||||||
|
render(
|
||||||
|
<TransactionCard
|
||||||
|
transaction={mockTransaction}
|
||||||
|
onDelete={mockOnDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Open dialog
|
// Open dialog
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
|
|
||||||
// Click delete
|
// Click delete
|
||||||
const deleteButton = screen.getByText('Delete');
|
const deleteButton = screen.getByText("Delete");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(mockOnDelete).toHaveBeenCalledWith('test-transaction-1');
|
expect(mockOnDelete).toHaveBeenCalledWith("test-transaction-1");
|
||||||
|
|
||||||
// Wait for the promise to be resolved/rejected
|
// Wait for the promise to be resolved/rejected
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
'트랜잭션 삭제 처리 중 오류:',
|
"트랜잭션 삭제 처리 중 오류:",
|
||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
);
|
);
|
||||||
}, { timeout: 1000 });
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles both sync and async onDelete functions', async () => {
|
it("handles both sync and async onDelete functions", async () => {
|
||||||
// Test sync function
|
// Test sync function
|
||||||
const syncOnDelete = vi.fn().mockReturnValue(true);
|
const syncOnDelete = vi.fn().mockReturnValue(true);
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TransactionCard transaction={mockTransaction} onDelete={syncOnDelete} />
|
<TransactionCard
|
||||||
|
transaction={mockTransaction}
|
||||||
|
onDelete={syncOnDelete}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
let card = screen.getByTestId('transaction-card');
|
let card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
let deleteButton = screen.getByText('Delete');
|
let deleteButton = screen.getByText("Delete");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(syncOnDelete).toHaveBeenCalledWith('test-transaction-1');
|
expect(syncOnDelete).toHaveBeenCalledWith("test-transaction-1");
|
||||||
|
|
||||||
// Test async function
|
// Test async function
|
||||||
const asyncOnDelete = vi.fn().mockResolvedValue(true);
|
const asyncOnDelete = vi.fn().mockResolvedValue(true);
|
||||||
rerender(<TransactionCard transaction={mockTransaction} onDelete={asyncOnDelete} />);
|
rerender(
|
||||||
|
<TransactionCard
|
||||||
|
transaction={mockTransaction}
|
||||||
|
onDelete={asyncOnDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
card = screen.getByTestId('transaction-card');
|
card = screen.getByTestId("transaction-card");
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
deleteButton = screen.getByText('Delete');
|
deleteButton = screen.getByText("Delete");
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(asyncOnDelete).toHaveBeenCalledWith('test-transaction-1');
|
expect(asyncOnDelete).toHaveBeenCalledWith("test-transaction-1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CSS classes and styling', () => {
|
describe("CSS classes and styling", () => {
|
||||||
it('applies correct CSS classes to the card', () => {
|
it("applies correct CSS classes to the card", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
expect(card).toHaveClass(
|
expect(card).toHaveClass(
|
||||||
'neuro-flat',
|
"neuro-flat",
|
||||||
'p-4',
|
"p-4",
|
||||||
'transition-all',
|
"transition-all",
|
||||||
'duration-300',
|
"duration-300",
|
||||||
'hover:shadow-neuro-convex',
|
"hover:shadow-neuro-convex",
|
||||||
'animate-scale-in',
|
"animate-scale-in",
|
||||||
'cursor-pointer'
|
"cursor-pointer"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has correct layout structure', () => {
|
it("has correct layout structure", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
const flexContainer = card.querySelector('.flex.items-center.justify-between');
|
const flexContainer = card.querySelector(
|
||||||
|
".flex.items-center.justify-between"
|
||||||
|
);
|
||||||
expect(flexContainer).toBeInTheDocument();
|
expect(flexContainer).toBeInTheDocument();
|
||||||
|
|
||||||
const leftSection = card.querySelector('.flex.items-center.gap-3');
|
const leftSection = card.querySelector(".flex.items-center.gap-3");
|
||||||
expect(leftSection).toBeInTheDocument();
|
expect(leftSection).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('accessibility', () => {
|
describe("accessibility", () => {
|
||||||
it('is keyboard accessible', () => {
|
it("is keyboard accessible", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
const card = screen.getByTestId('transaction-card');
|
const card = screen.getByTestId("transaction-card");
|
||||||
expect(card).toHaveClass('cursor-pointer');
|
expect(card).toHaveClass("cursor-pointer");
|
||||||
|
|
||||||
// Should be clickable
|
// Should be clickable
|
||||||
fireEvent.click(card);
|
fireEvent.click(card);
|
||||||
expect(screen.getByTestId('transaction-edit-dialog')).toBeInTheDocument();
|
expect(screen.getByTestId("transaction-edit-dialog")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides semantic content for screen readers', () => {
|
it("provides semantic content for screen readers", () => {
|
||||||
render(<TransactionCard transaction={mockTransaction} />);
|
render(<TransactionCard transaction={mockTransaction} />);
|
||||||
|
|
||||||
// All important information should be accessible to screen readers
|
// All important information should be accessible to screen readers
|
||||||
expect(screen.getByText('Coffee Shop')).toBeInTheDocument();
|
expect(screen.getByText("Coffee Shop")).toBeInTheDocument();
|
||||||
expect(screen.getByText('2024-06-15')).toBeInTheDocument();
|
expect(screen.getByText("2024-06-15")).toBeInTheDocument();
|
||||||
expect(screen.getByText('5000원')).toBeInTheDocument();
|
expect(screen.getByText("5000원")).toBeInTheDocument();
|
||||||
expect(screen.getByText('Food icon')).toBeInTheDocument();
|
expect(screen.getByText("Food icon")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe("edge cases", () => {
|
||||||
it('handles missing optional transaction fields', () => {
|
it("handles missing optional transaction fields", () => {
|
||||||
const minimalTransaction: Transaction = {
|
const minimalTransaction: Transaction = {
|
||||||
id: 'minimal-transaction',
|
id: "minimal-transaction",
|
||||||
title: 'Minimal',
|
title: "Minimal",
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
date: '2024-01-01',
|
date: "2024-01-01",
|
||||||
category: 'Other',
|
category: "Other",
|
||||||
type: 'expense',
|
type: "expense",
|
||||||
// paymentMethod is optional
|
// paymentMethod is optional
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TransactionCard transaction={minimalTransaction} />);
|
render(<TransactionCard transaction={minimalTransaction} />);
|
||||||
|
|
||||||
expect(screen.getByText('Minimal')).toBeInTheDocument();
|
expect(screen.getByText("Minimal")).toBeInTheDocument();
|
||||||
expect(screen.getByText('2024-01-01')).toBeInTheDocument();
|
expect(screen.getByText("2024-01-01")).toBeInTheDocument();
|
||||||
expect(screen.getByText('1000원')).toBeInTheDocument();
|
expect(screen.getByText("1000원")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles very long transaction titles', () => {
|
it("handles very long transaction titles", () => {
|
||||||
const longTitleTransaction: Transaction = {
|
const longTitleTransaction: Transaction = {
|
||||||
...mockTransaction,
|
...mockTransaction,
|
||||||
title: 'This is a very long transaction title that might overflow the container and cause layout issues',
|
title:
|
||||||
|
"This is a very long transaction title that might overflow the container and cause layout issues",
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TransactionCard transaction={longTitleTransaction} />);
|
render(<TransactionCard transaction={longTitleTransaction} />);
|
||||||
@@ -292,7 +324,7 @@ describe('TransactionCard', () => {
|
|||||||
expect(screen.getByText(longTitleTransaction.title)).toBeInTheDocument();
|
expect(screen.getByText(longTitleTransaction.title)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles zero and negative amounts', () => {
|
it("handles zero and negative amounts", () => {
|
||||||
const zeroAmountTransaction: Transaction = {
|
const zeroAmountTransaction: Transaction = {
|
||||||
...mockTransaction,
|
...mockTransaction,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -303,11 +335,13 @@ describe('TransactionCard', () => {
|
|||||||
amount: -5000,
|
amount: -5000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rerender } = render(<TransactionCard transaction={zeroAmountTransaction} />);
|
const { rerender } = render(
|
||||||
expect(screen.getByText('0원')).toBeInTheDocument();
|
<TransactionCard transaction={zeroAmountTransaction} />
|
||||||
|
);
|
||||||
|
expect(screen.getByText("0원")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(<TransactionCard transaction={negativeAmountTransaction} />);
|
rerender(<TransactionCard transaction={negativeAmountTransaction} />);
|
||||||
expect(screen.getByText('-5000원')).toBeInTheDocument();
|
expect(screen.getByText("-5000원")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -48,25 +48,33 @@ const IndexContent: React.FC = memo(() => {
|
|||||||
}, [budgetData]);
|
}, [budgetData]);
|
||||||
|
|
||||||
// 콜백 함수들 메모이제이션
|
// 콜백 함수들 메모이제이션
|
||||||
const handleTabChange = useCallback((tab: string) => {
|
const handleTabChange = useCallback(
|
||||||
|
(tab: string) => {
|
||||||
setSelectedTab(tab);
|
setSelectedTab(tab);
|
||||||
}, [setSelectedTab]);
|
},
|
||||||
|
[setSelectedTab]
|
||||||
|
);
|
||||||
|
|
||||||
const handleBudgetUpdate = useCallback((
|
const handleBudgetUpdate = useCallback(
|
||||||
type: any,
|
(type: any, amount: number, categoryBudgets?: Record<string, number>) => {
|
||||||
amount: number,
|
|
||||||
categoryBudgets?: Record<string, number>
|
|
||||||
) => {
|
|
||||||
handleBudgetGoalUpdate(type, amount, categoryBudgets);
|
handleBudgetGoalUpdate(type, amount, categoryBudgets);
|
||||||
}, [handleBudgetGoalUpdate]);
|
},
|
||||||
|
[handleBudgetGoalUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTransactionUpdate = useCallback((transaction: any) => {
|
const handleTransactionUpdate = useCallback(
|
||||||
|
(transaction: any) => {
|
||||||
updateTransaction(transaction);
|
updateTransaction(transaction);
|
||||||
}, [updateTransaction]);
|
},
|
||||||
|
[updateTransaction]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCategorySpending = useCallback((category: string) => {
|
const handleCategorySpending = useCallback(
|
||||||
|
(category: string) => {
|
||||||
return getCategorySpending(category);
|
return getCategorySpending(category);
|
||||||
}, [getCategorySpending]);
|
},
|
||||||
|
[getCategorySpending]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto px-6">
|
<div className="max-w-md mx-auto px-6">
|
||||||
@@ -85,6 +93,6 @@ const IndexContent: React.FC = memo(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
IndexContent.displayName = 'IndexContent';
|
IndexContent.displayName = "IndexContent";
|
||||||
|
|
||||||
export default IndexContent;
|
export default IndexContent;
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
* 네트워크 연결 상태를 모니터링하고 오프라인 시 적절한 대응을 제공합니다.
|
* 네트워크 연결 상태를 모니터링하고 오프라인 시 적절한 대응을 제공합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from '@/hooks/useToast.wrapper';
|
import { toast } from "@/hooks/useToast.wrapper";
|
||||||
import { syncLogger } from '@/utils/logger';
|
import { syncLogger } from "@/utils/logger";
|
||||||
import { offlineStrategies } from '@/lib/query/cacheStrategies';
|
import { offlineStrategies } from "@/lib/query/cacheStrategies";
|
||||||
import { useAppStore } from '@/stores';
|
import { useAppStore } from "@/stores";
|
||||||
|
|
||||||
interface OfflineManagerProps {
|
interface OfflineManagerProps {
|
||||||
/** 오프라인 상태 알림 표시 여부 */
|
/** 오프라인 상태 알림 표시 여부 */
|
||||||
@@ -23,7 +23,7 @@ interface OfflineManagerProps {
|
|||||||
*/
|
*/
|
||||||
export const OfflineManager = ({
|
export const OfflineManager = ({
|
||||||
showOfflineToast = true,
|
showOfflineToast = true,
|
||||||
autoSyncOnReconnect = true
|
autoSyncOnReconnect = true,
|
||||||
}: OfflineManagerProps) => {
|
}: OfflineManagerProps) => {
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
const [wasOffline, setWasOffline] = useState(false);
|
const [wasOffline, setWasOffline] = useState(false);
|
||||||
@@ -36,14 +36,15 @@ export const OfflineManager = ({
|
|||||||
setIsOnline(true);
|
setIsOnline(true);
|
||||||
setOnlineStatus(true);
|
setOnlineStatus(true);
|
||||||
|
|
||||||
syncLogger.info('네트워크 연결 복구됨');
|
syncLogger.info("네트워크 연결 복구됨");
|
||||||
|
|
||||||
if (wasOffline) {
|
if (wasOffline) {
|
||||||
// 오프라인에서 온라인으로 복구된 경우
|
// 오프라인에서 온라인으로 복구된 경우
|
||||||
if (showOfflineToast) {
|
if (showOfflineToast) {
|
||||||
toast({
|
toast({
|
||||||
title: "연결 복구",
|
title: "연결 복구",
|
||||||
description: "인터넷 연결이 복구되었습니다. 데이터를 동기화하는 중...",
|
description:
|
||||||
|
"인터넷 연결이 복구되었습니다. 데이터를 동기화하는 중...",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ export const OfflineManager = ({
|
|||||||
// 연결 복구 시 캐시된 데이터 동기화
|
// 연결 복구 시 캐시된 데이터 동기화
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
type: 'active',
|
type: "active",
|
||||||
stale: true
|
stale: true,
|
||||||
});
|
});
|
||||||
}, 1000); // 1초 후 리페치 (네트워크 안정화 대기)
|
}, 1000); // 1초 후 리페치 (네트워크 안정화 대기)
|
||||||
}
|
}
|
||||||
@@ -66,12 +67,13 @@ export const OfflineManager = ({
|
|||||||
setOnlineStatus(false);
|
setOnlineStatus(false);
|
||||||
setWasOffline(true);
|
setWasOffline(true);
|
||||||
|
|
||||||
syncLogger.warn('네트워크 연결 끊어짐');
|
syncLogger.warn("네트워크 연결 끊어짐");
|
||||||
|
|
||||||
if (showOfflineToast) {
|
if (showOfflineToast) {
|
||||||
toast({
|
toast({
|
||||||
title: "연결 끊어짐",
|
title: "연결 끊어짐",
|
||||||
description: "인터넷 연결이 끊어졌습니다. 오프라인 모드로 전환됩니다.",
|
description:
|
||||||
|
"인터넷 연결이 끊어졌습니다. 오프라인 모드로 전환됩니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,35 +83,41 @@ export const OfflineManager = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 네트워크 상태 변경 감지 설정
|
// 네트워크 상태 변경 감지 설정
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
window.addEventListener('offline', handleOffline);
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
// 초기 상태 설정
|
// 초기 상태 설정
|
||||||
setOnlineStatus(navigator.onLine);
|
setOnlineStatus(navigator.onLine);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener("online", handleOnline);
|
||||||
window.removeEventListener('offline', handleOffline);
|
window.removeEventListener("offline", handleOffline);
|
||||||
};
|
};
|
||||||
}, [wasOffline, showOfflineToast, autoSyncOnReconnect, queryClient, setOnlineStatus]);
|
}, [
|
||||||
|
wasOffline,
|
||||||
|
showOfflineToast,
|
||||||
|
autoSyncOnReconnect,
|
||||||
|
queryClient,
|
||||||
|
setOnlineStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
// 주기적 연결 상태 확인 (네이티브 이벤트 보완)
|
// 주기적 연결 상태 확인 (네이티브 이벤트 보완)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkConnection = async () => {
|
const checkConnection = async () => {
|
||||||
try {
|
try {
|
||||||
// 간단한 네트워크 요청으로 실제 연결 상태 확인
|
// 간단한 네트워크 요청으로 실제 연결 상태 확인
|
||||||
const response = await fetch('/api/health', {
|
const response = await fetch("/api/health", {
|
||||||
method: 'HEAD',
|
method: "HEAD",
|
||||||
mode: 'no-cors',
|
mode: "no-cors",
|
||||||
cache: 'no-cache'
|
cache: "no-cache",
|
||||||
});
|
});
|
||||||
|
|
||||||
const actuallyOnline = response.ok || response.type === 'opaque';
|
const actuallyOnline = response.ok || response.type === "opaque";
|
||||||
|
|
||||||
if (actuallyOnline !== isOnline) {
|
if (actuallyOnline !== isOnline) {
|
||||||
syncLogger.info('실제 네트워크 상태와 감지된 상태가 다름', {
|
syncLogger.info("실제 네트워크 상태와 감지된 상태가 다름", {
|
||||||
detected: isOnline,
|
detected: isOnline,
|
||||||
actual: actuallyOnline
|
actual: actuallyOnline,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsOnline(actuallyOnline);
|
setIsOnline(actuallyOnline);
|
||||||
@@ -118,7 +126,7 @@ export const OfflineManager = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 요청 실패 시 오프라인으로 간주
|
// 요청 실패 시 오프라인으로 간주
|
||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
syncLogger.warn('네트워크 상태 확인 실패 - 오프라인으로 간주');
|
syncLogger.warn("네트워크 상태 확인 실패 - 오프라인으로 간주");
|
||||||
setIsOnline(false);
|
setIsOnline(false);
|
||||||
setOnlineStatus(false);
|
setOnlineStatus(false);
|
||||||
setWasOffline(true);
|
setWasOffline(true);
|
||||||
@@ -151,7 +159,7 @@ export const OfflineManager = ({
|
|||||||
queryClient.setDefaultOptions({
|
queryClient.setDefaultOptions({
|
||||||
queries: {
|
queries: {
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
if (error?.code === 'NETWORK_ERROR' || error?.status >= 500) {
|
if (error?.code === "NETWORK_ERROR" || error?.status >= 500) {
|
||||||
return failureCount < 3;
|
return failureCount < 3;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -161,7 +169,7 @@ export const OfflineManager = ({
|
|||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
if (error?.code === 'NETWORK_ERROR') {
|
if (error?.code === "NETWORK_ERROR") {
|
||||||
return failureCount < 2;
|
return failureCount < 2;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -174,17 +182,21 @@ export const OfflineManager = ({
|
|||||||
// 장시간 오프라인 상태 감지
|
// 장시간 오프라인 상태 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
const longOfflineTimer = setTimeout(() => {
|
const longOfflineTimer = setTimeout(
|
||||||
syncLogger.warn('장시간 오프라인 상태 감지');
|
() => {
|
||||||
|
syncLogger.warn("장시간 오프라인 상태 감지");
|
||||||
|
|
||||||
if (showOfflineToast) {
|
if (showOfflineToast) {
|
||||||
toast({
|
toast({
|
||||||
title: "장시간 오프라인",
|
title: "장시간 오프라인",
|
||||||
description: "연결이 오랫동안 끊어져 있습니다. 일부 기능이 제한될 수 있습니다.",
|
description:
|
||||||
|
"연결이 오랫동안 끊어져 있습니다. 일부 기능이 제한될 수 있습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000); // 5분 후
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
); // 5분 후
|
||||||
|
|
||||||
return () => clearTimeout(longOfflineTimer);
|
return () => clearTimeout(longOfflineTimer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
* 애플리케이션 전체의 캐시 전략을 관리하고 최적화합니다.
|
* 애플리케이션 전체의 캐시 전략을 관리하고 최적화합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { autoCacheManagement, offlineStrategies, cacheOptimization } from '@/lib/query/cacheStrategies';
|
import {
|
||||||
import { useAuthStore } from '@/stores';
|
autoCacheManagement,
|
||||||
import { syncLogger } from '@/utils/logger';
|
offlineStrategies,
|
||||||
|
cacheOptimization,
|
||||||
|
} from "@/lib/query/cacheStrategies";
|
||||||
|
import { useAuthStore } from "@/stores";
|
||||||
|
import { syncLogger } from "@/utils/logger";
|
||||||
|
|
||||||
interface QueryCacheManagerProps {
|
interface QueryCacheManagerProps {
|
||||||
/** 주기적 캐시 정리 간격 (분) */
|
/** 주기적 캐시 정리 간격 (분) */
|
||||||
@@ -25,14 +29,14 @@ interface QueryCacheManagerProps {
|
|||||||
export const QueryCacheManager = ({
|
export const QueryCacheManager = ({
|
||||||
cleanupIntervalMinutes = 30,
|
cleanupIntervalMinutes = 30,
|
||||||
enableOfflineCache = true,
|
enableOfflineCache = true,
|
||||||
enableCacheAnalysis = import.meta.env.DEV
|
enableCacheAnalysis = import.meta.env.DEV,
|
||||||
}: QueryCacheManagerProps) => {
|
}: QueryCacheManagerProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, session } = useAuthStore();
|
const { user, session } = useAuthStore();
|
||||||
|
|
||||||
// 캐시 관리 초기화
|
// 캐시 관리 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncLogger.info('React Query 캐시 관리 초기화 시작');
|
syncLogger.info("React Query 캐시 관리 초기화 시작");
|
||||||
|
|
||||||
// 브라우저 이벤트 핸들러 설정
|
// 브라우저 이벤트 핸들러 설정
|
||||||
autoCacheManagement.setupBrowserEventHandlers();
|
autoCacheManagement.setupBrowserEventHandlers();
|
||||||
@@ -43,20 +47,25 @@ export const QueryCacheManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 주기적 캐시 정리 시작
|
// 주기적 캐시 정리 시작
|
||||||
const cleanupInterval = autoCacheManagement.startPeriodicCleanup(cleanupIntervalMinutes);
|
const cleanupInterval = autoCacheManagement.startPeriodicCleanup(
|
||||||
|
cleanupIntervalMinutes
|
||||||
|
);
|
||||||
|
|
||||||
// 개발 모드에서 캐시 분석
|
// 개발 모드에서 캐시 분석
|
||||||
let analysisInterval: NodeJS.Timeout | null = null;
|
let analysisInterval: NodeJS.Timeout | null = null;
|
||||||
if (enableCacheAnalysis) {
|
if (enableCacheAnalysis) {
|
||||||
analysisInterval = setInterval(() => {
|
analysisInterval = setInterval(
|
||||||
|
() => {
|
||||||
cacheOptimization.analyzeCacheHitRate();
|
cacheOptimization.analyzeCacheHitRate();
|
||||||
}, 5 * 60 * 1000); // 5분마다 분석
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
); // 5분마다 분석
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('React Query 캐시 관리 초기화 완료', {
|
syncLogger.info("React Query 캐시 관리 초기화 완료", {
|
||||||
cleanupIntervalMinutes,
|
cleanupIntervalMinutes,
|
||||||
enableOfflineCache,
|
enableOfflineCache,
|
||||||
enableCacheAnalysis
|
enableCacheAnalysis,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 정리 함수
|
// 정리 함수
|
||||||
@@ -71,7 +80,7 @@ export const QueryCacheManager = ({
|
|||||||
offlineStrategies.cacheForOffline();
|
offlineStrategies.cacheForOffline();
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('React Query 캐시 관리 정리 완료');
|
syncLogger.info("React Query 캐시 관리 정리 완료");
|
||||||
};
|
};
|
||||||
}, [cleanupIntervalMinutes, enableOfflineCache, enableCacheAnalysis]);
|
}, [cleanupIntervalMinutes, enableOfflineCache, enableCacheAnalysis]);
|
||||||
|
|
||||||
@@ -80,38 +89,41 @@ export const QueryCacheManager = ({
|
|||||||
if (!user || !session) {
|
if (!user || !session) {
|
||||||
// 로그아웃 시 민감한 데이터 캐시 정리
|
// 로그아웃 시 민감한 데이터 캐시 정리
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
syncLogger.info('로그아웃으로 인한 캐시 전체 정리');
|
syncLogger.info("로그아웃으로 인한 캐시 전체 정리");
|
||||||
}
|
}
|
||||||
}, [user, session, queryClient]);
|
}, [user, session, queryClient]);
|
||||||
|
|
||||||
// 메모리 압박 상황 감지 및 대응
|
// 메모리 압박 상황 감지 및 대응
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMemoryPressure = () => {
|
const handleMemoryPressure = () => {
|
||||||
syncLogger.warn('메모리 압박 감지 - 캐시 최적화 실행');
|
syncLogger.warn("메모리 압박 감지 - 캐시 최적화 실행");
|
||||||
cacheOptimization.optimizeMemoryUsage();
|
cacheOptimization.optimizeMemoryUsage();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Performance Observer를 통한 메모리 모니터링 (지원되는 브라우저에서만)
|
// Performance Observer를 통한 메모리 모니터링 (지원되는 브라우저에서만)
|
||||||
if ('PerformanceObserver' in window) {
|
if ("PerformanceObserver" in window) {
|
||||||
try {
|
try {
|
||||||
const observer = new PerformanceObserver((list) => {
|
const observer = new PerformanceObserver((list) => {
|
||||||
const entries = list.getEntries();
|
const entries = list.getEntries();
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
// 메모리 관련 성능 지표 확인
|
// 메모리 관련 성능 지표 확인
|
||||||
if (entry.entryType === 'memory') {
|
if (entry.entryType === "memory") {
|
||||||
const memoryEntry = entry as any;
|
const memoryEntry = entry as any;
|
||||||
if (memoryEntry.usedJSHeapSize > memoryEntry.totalJSHeapSize * 0.9) {
|
if (
|
||||||
|
memoryEntry.usedJSHeapSize >
|
||||||
|
memoryEntry.totalJSHeapSize * 0.9
|
||||||
|
) {
|
||||||
handleMemoryPressure();
|
handleMemoryPressure();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe({ entryTypes: ['memory'] });
|
observer.observe({ entryTypes: ["memory"] });
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncLogger.warn('Performance Observer 설정 실패', error);
|
syncLogger.warn("Performance Observer 설정 실패", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -123,25 +135,25 @@ export const QueryCacheManager = ({
|
|||||||
|
|
||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
// 온라인 상태: 적극적인 캐시 무효화
|
// 온라인 상태: 적극적인 캐시 무효화
|
||||||
syncLogger.info('온라인 상태 - 적극적 캐시 전략 활성화');
|
syncLogger.info("온라인 상태 - 적극적 캐시 전략 활성화");
|
||||||
} else {
|
} else {
|
||||||
// 오프라인 상태: 보수적인 캐시 전략
|
// 오프라인 상태: 보수적인 캐시 전략
|
||||||
syncLogger.info('오프라인 상태 - 보수적 캐시 전략 활성화');
|
syncLogger.info("오프라인 상태 - 보수적 캐시 전략 활성화");
|
||||||
if (enableOfflineCache) {
|
if (enableOfflineCache) {
|
||||||
offlineStrategies.cacheForOffline();
|
offlineStrategies.cacheForOffline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', updateCacheStrategy);
|
window.addEventListener("online", updateCacheStrategy);
|
||||||
window.addEventListener('offline', updateCacheStrategy);
|
window.addEventListener("offline", updateCacheStrategy);
|
||||||
|
|
||||||
// 초기 상태 설정
|
// 초기 상태 설정
|
||||||
updateCacheStrategy();
|
updateCacheStrategy();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('online', updateCacheStrategy);
|
window.removeEventListener("online", updateCacheStrategy);
|
||||||
window.removeEventListener('offline', updateCacheStrategy);
|
window.removeEventListener("offline", updateCacheStrategy);
|
||||||
};
|
};
|
||||||
}, [enableOfflineCache]);
|
}, [enableOfflineCache]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* React Query와 함께 작동하여 백그라운드에서 자동으로 데이터를 동기화합니다.
|
* React Query와 함께 작동하여 백그라운드에서 자동으로 데이터를 동기화합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useAutoSyncQuery, useSync } from '@/hooks/query';
|
import { useAutoSyncQuery, useSync } from "@/hooks/query";
|
||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from "@/stores";
|
||||||
import { syncLogger } from '@/utils/logger';
|
import { syncLogger } from "@/utils/logger";
|
||||||
|
|
||||||
interface BackgroundSyncProps {
|
interface BackgroundSyncProps {
|
||||||
/** 자동 동기화 간격 (분) */
|
/** 자동 동기화 간격 (분) */
|
||||||
@@ -24,7 +24,7 @@ interface BackgroundSyncProps {
|
|||||||
export const BackgroundSync = ({
|
export const BackgroundSync = ({
|
||||||
intervalMinutes = 5,
|
intervalMinutes = 5,
|
||||||
syncOnFocus = true,
|
syncOnFocus = true,
|
||||||
syncOnOnline = true
|
syncOnOnline = true,
|
||||||
}: BackgroundSyncProps) => {
|
}: BackgroundSyncProps) => {
|
||||||
const { user, session } = useAuthStore();
|
const { user, session } = useAuthStore();
|
||||||
const { triggerBackgroundSync } = useSync();
|
const { triggerBackgroundSync } = useSync();
|
||||||
@@ -37,23 +37,23 @@ export const BackgroundSync = ({
|
|||||||
if (!syncOnFocus || !user?.id) return;
|
if (!syncOnFocus || !user?.id) return;
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
syncLogger.info('윈도우 포커스 감지 - 백그라운드 동기화 실행');
|
syncLogger.info("윈도우 포커스 감지 - 백그라운드 동기화 실행");
|
||||||
triggerBackgroundSync();
|
triggerBackgroundSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
syncLogger.info('페이지 가시성 복구 - 백그라운드 동기화 실행');
|
syncLogger.info("페이지 가시성 복구 - 백그라운드 동기화 실행");
|
||||||
triggerBackgroundSync();
|
triggerBackgroundSync();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('focus', handleFocus);
|
window.addEventListener("focus", handleFocus);
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('focus', handleFocus);
|
window.removeEventListener("focus", handleFocus);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [user?.id, syncOnFocus, triggerBackgroundSync]);
|
}, [user?.id, syncOnFocus, triggerBackgroundSync]);
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ export const BackgroundSync = ({
|
|||||||
if (!syncOnOnline || !user?.id) return;
|
if (!syncOnOnline || !user?.id) return;
|
||||||
|
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
syncLogger.info('네트워크 연결 복구 - 백그라운드 동기화 실행');
|
syncLogger.info("네트워크 연결 복구 - 백그라운드 동기화 실행");
|
||||||
triggerBackgroundSync();
|
triggerBackgroundSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener("online", handleOnline);
|
||||||
};
|
};
|
||||||
}, [user?.id, syncOnOnline, triggerBackgroundSync]);
|
}, [user?.id, syncOnOnline, triggerBackgroundSync]);
|
||||||
|
|
||||||
// 세션 변경 시 동기화
|
// 세션 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session && user?.id) {
|
if (session && user?.id) {
|
||||||
syncLogger.info('세션 변경 감지 - 백그라운드 동기화 실행');
|
syncLogger.info("세션 변경 감지 - 백그라운드 동기화 실행");
|
||||||
triggerBackgroundSync();
|
triggerBackgroundSync();
|
||||||
}
|
}
|
||||||
}, [session, user?.id, triggerBackgroundSync]);
|
}, [session, user?.id, triggerBackgroundSync]);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { calculateUpdatedBudgetData } from '../budgetCalculation';
|
import { calculateUpdatedBudgetData } from "../budgetCalculation";
|
||||||
import { BudgetData, BudgetPeriod } from '../../types';
|
import { BudgetData, BudgetPeriod } from "../../types";
|
||||||
import { getInitialBudgetData } from '../constants';
|
import { getInitialBudgetData } from "../constants";
|
||||||
|
|
||||||
// Mock logger to prevent console output during tests
|
// Mock logger to prevent console output during tests
|
||||||
vi.mock('@/utils/logger', () => ({
|
vi.mock("@/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -12,7 +12,7 @@ vi.mock('@/utils/logger', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock constants
|
// Mock constants
|
||||||
vi.mock('../constants', () => ({
|
vi.mock("../constants", () => ({
|
||||||
getInitialBudgetData: vi.fn(() => ({
|
getInitialBudgetData: vi.fn(() => ({
|
||||||
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
||||||
weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
||||||
@@ -20,7 +20,7 @@ vi.mock('../constants', () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('budgetCalculation', () => {
|
describe("budgetCalculation", () => {
|
||||||
let mockPrevBudgetData: BudgetData;
|
let mockPrevBudgetData: BudgetData;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,80 +28,134 @@ describe('budgetCalculation', () => {
|
|||||||
|
|
||||||
mockPrevBudgetData = {
|
mockPrevBudgetData = {
|
||||||
daily: { targetAmount: 10000, spentAmount: 5000, remainingAmount: 5000 },
|
daily: { targetAmount: 10000, spentAmount: 5000, remainingAmount: 5000 },
|
||||||
weekly: { targetAmount: 70000, spentAmount: 30000, remainingAmount: 40000 },
|
weekly: {
|
||||||
monthly: { targetAmount: 300000, spentAmount: 100000, remainingAmount: 200000 },
|
targetAmount: 70000,
|
||||||
|
spentAmount: 30000,
|
||||||
|
remainingAmount: 40000,
|
||||||
|
},
|
||||||
|
monthly: {
|
||||||
|
targetAmount: 300000,
|
||||||
|
spentAmount: 100000,
|
||||||
|
remainingAmount: 200000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateUpdatedBudgetData', () => {
|
describe("calculateUpdatedBudgetData", () => {
|
||||||
describe('monthly budget input', () => {
|
describe("monthly budget input", () => {
|
||||||
it('calculates weekly and daily budgets from monthly amount', () => {
|
it("calculates weekly and daily budgets from monthly amount", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 300000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
300000
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.monthly.targetAmount).toBe(300000);
|
expect(result.monthly.targetAmount).toBe(300000);
|
||||||
expect(result.weekly.targetAmount).toBe(Math.round(300000 / 4.345)); // ~69043
|
expect(result.weekly.targetAmount).toBe(Math.round(300000 / 4.345)); // ~69043
|
||||||
expect(result.daily.targetAmount).toBe(Math.round(300000 / 30)); // 10000
|
expect(result.daily.targetAmount).toBe(Math.round(300000 / 30)); // 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves existing spent amounts', () => {
|
it("preserves existing spent amounts", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 600000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
600000
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.daily.spentAmount).toBe(5000);
|
expect(result.daily.spentAmount).toBe(5000);
|
||||||
expect(result.weekly.spentAmount).toBe(30000);
|
expect(result.weekly.spentAmount).toBe(30000);
|
||||||
expect(result.monthly.spentAmount).toBe(100000);
|
expect(result.monthly.spentAmount).toBe(100000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates remaining amounts correctly', () => {
|
it("calculates remaining amounts correctly", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 500000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
500000
|
||||||
|
);
|
||||||
|
|
||||||
const expectedWeekly = Math.round(500000 / 4.345);
|
const expectedWeekly = Math.round(500000 / 4.345);
|
||||||
const expectedDaily = Math.round(500000 / 30);
|
const expectedDaily = Math.round(500000 / 30);
|
||||||
|
|
||||||
expect(result.daily.remainingAmount).toBe(Math.max(0, expectedDaily - 5000));
|
expect(result.daily.remainingAmount).toBe(
|
||||||
expect(result.weekly.remainingAmount).toBe(Math.max(0, expectedWeekly - 30000));
|
Math.max(0, expectedDaily - 5000)
|
||||||
expect(result.monthly.remainingAmount).toBe(Math.max(0, 500000 - 100000));
|
);
|
||||||
|
expect(result.weekly.remainingAmount).toBe(
|
||||||
|
Math.max(0, expectedWeekly - 30000)
|
||||||
|
);
|
||||||
|
expect(result.monthly.remainingAmount).toBe(
|
||||||
|
Math.max(0, 500000 - 100000)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('weekly budget input', () => {
|
describe("weekly budget input", () => {
|
||||||
it('converts weekly amount to monthly and calculates others', () => {
|
it("converts weekly amount to monthly and calculates others", () => {
|
||||||
const weeklyAmount = 80000;
|
const weeklyAmount = 80000;
|
||||||
const expectedMonthly = Math.round(weeklyAmount * 4.345); // ~347600
|
const expectedMonthly = Math.round(weeklyAmount * 4.345); // ~347600
|
||||||
|
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'weekly', weeklyAmount);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"weekly",
|
||||||
|
weeklyAmount
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.monthly.targetAmount).toBe(expectedMonthly);
|
expect(result.monthly.targetAmount).toBe(expectedMonthly);
|
||||||
expect(result.weekly.targetAmount).toBe(Math.round(expectedMonthly / 4.345));
|
expect(result.weekly.targetAmount).toBe(
|
||||||
expect(result.daily.targetAmount).toBe(Math.round(expectedMonthly / 30));
|
Math.round(expectedMonthly / 4.345)
|
||||||
|
);
|
||||||
|
expect(result.daily.targetAmount).toBe(
|
||||||
|
Math.round(expectedMonthly / 30)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge case weekly amounts', () => {
|
it("handles edge case weekly amounts", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'weekly', 1);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"weekly",
|
||||||
|
1
|
||||||
|
);
|
||||||
expect(result.monthly.targetAmount).toBe(Math.round(1 * 4.345));
|
expect(result.monthly.targetAmount).toBe(Math.round(1 * 4.345));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('daily budget input', () => {
|
describe("daily budget input", () => {
|
||||||
it('converts daily amount to monthly and calculates others', () => {
|
it("converts daily amount to monthly and calculates others", () => {
|
||||||
const dailyAmount = 15000;
|
const dailyAmount = 15000;
|
||||||
const expectedMonthly = Math.round(dailyAmount * 30); // 450000
|
const expectedMonthly = Math.round(dailyAmount * 30); // 450000
|
||||||
|
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'daily', dailyAmount);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"daily",
|
||||||
|
dailyAmount
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.monthly.targetAmount).toBe(expectedMonthly);
|
expect(result.monthly.targetAmount).toBe(expectedMonthly);
|
||||||
expect(result.weekly.targetAmount).toBe(Math.round(expectedMonthly / 4.345));
|
expect(result.weekly.targetAmount).toBe(
|
||||||
expect(result.daily.targetAmount).toBe(Math.round(expectedMonthly / 30));
|
Math.round(expectedMonthly / 4.345)
|
||||||
|
);
|
||||||
|
expect(result.daily.targetAmount).toBe(
|
||||||
|
Math.round(expectedMonthly / 30)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge case daily amounts', () => {
|
it("handles edge case daily amounts", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'daily', 1);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"daily",
|
||||||
|
1
|
||||||
|
);
|
||||||
expect(result.monthly.targetAmount).toBe(30);
|
expect(result.monthly.targetAmount).toBe(30);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe("edge cases and error handling", () => {
|
||||||
it('handles null/undefined previous budget data', () => {
|
it("handles null/undefined previous budget data", () => {
|
||||||
const result = calculateUpdatedBudgetData(null as any, 'monthly', 300000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
null as any,
|
||||||
|
"monthly",
|
||||||
|
300000
|
||||||
|
);
|
||||||
|
|
||||||
expect(getInitialBudgetData).toHaveBeenCalled();
|
expect(getInitialBudgetData).toHaveBeenCalled();
|
||||||
expect(result.monthly.targetAmount).toBe(300000);
|
expect(result.monthly.targetAmount).toBe(300000);
|
||||||
@@ -110,8 +164,12 @@ describe('budgetCalculation', () => {
|
|||||||
expect(result.monthly.spentAmount).toBe(0);
|
expect(result.monthly.spentAmount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles zero amount input', () => {
|
it("handles zero amount input", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 0);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.monthly.targetAmount).toBe(0);
|
expect(result.monthly.targetAmount).toBe(0);
|
||||||
expect(result.weekly.targetAmount).toBe(0);
|
expect(result.weekly.targetAmount).toBe(0);
|
||||||
@@ -121,14 +179,30 @@ describe('budgetCalculation', () => {
|
|||||||
expect(result.monthly.remainingAmount).toBe(0);
|
expect(result.monthly.remainingAmount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles negative remaining amounts (when spent > target)', () => {
|
it("handles negative remaining amounts (when spent > target)", () => {
|
||||||
const highSpentBudgetData: BudgetData = {
|
const highSpentBudgetData: BudgetData = {
|
||||||
daily: { targetAmount: 10000, spentAmount: 15000, remainingAmount: -5000 },
|
daily: {
|
||||||
weekly: { targetAmount: 70000, spentAmount: 80000, remainingAmount: -10000 },
|
targetAmount: 10000,
|
||||||
monthly: { targetAmount: 300000, spentAmount: 350000, remainingAmount: -50000 },
|
spentAmount: 15000,
|
||||||
|
remainingAmount: -5000,
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
targetAmount: 70000,
|
||||||
|
spentAmount: 80000,
|
||||||
|
remainingAmount: -10000,
|
||||||
|
},
|
||||||
|
monthly: {
|
||||||
|
targetAmount: 300000,
|
||||||
|
spentAmount: 350000,
|
||||||
|
remainingAmount: -50000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = calculateUpdatedBudgetData(highSpentBudgetData, 'monthly', 100000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
highSpentBudgetData,
|
||||||
|
"monthly",
|
||||||
|
100000
|
||||||
|
);
|
||||||
|
|
||||||
// remainingAmount should never be negative (Math.max with 0)
|
// remainingAmount should never be negative (Math.max with 0)
|
||||||
expect(result.daily.remainingAmount).toBe(0);
|
expect(result.daily.remainingAmount).toBe(0);
|
||||||
@@ -136,23 +210,33 @@ describe('budgetCalculation', () => {
|
|||||||
expect(result.monthly.remainingAmount).toBe(0);
|
expect(result.monthly.remainingAmount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles very large amounts', () => {
|
it("handles very large amounts", () => {
|
||||||
const largeAmount = 10000000; // 10 million
|
const largeAmount = 10000000; // 10 million
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', largeAmount);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
largeAmount
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.monthly.targetAmount).toBe(largeAmount);
|
expect(result.monthly.targetAmount).toBe(largeAmount);
|
||||||
expect(result.weekly.targetAmount).toBe(Math.round(largeAmount / 4.345));
|
expect(result.weekly.targetAmount).toBe(
|
||||||
|
Math.round(largeAmount / 4.345)
|
||||||
|
);
|
||||||
expect(result.daily.targetAmount).toBe(Math.round(largeAmount / 30));
|
expect(result.daily.targetAmount).toBe(Math.round(largeAmount / 30));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles missing spent amounts in previous data', () => {
|
it("handles missing spent amounts in previous data", () => {
|
||||||
const incompleteBudgetData = {
|
const incompleteBudgetData = {
|
||||||
daily: { targetAmount: 10000 } as any,
|
daily: { targetAmount: 10000 } as any,
|
||||||
weekly: { targetAmount: 70000, spentAmount: undefined } as any,
|
weekly: { targetAmount: 70000, spentAmount: undefined } as any,
|
||||||
monthly: { targetAmount: 300000, spentAmount: null } as any,
|
monthly: { targetAmount: 300000, spentAmount: null } as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = calculateUpdatedBudgetData(incompleteBudgetData, 'monthly', 400000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
incompleteBudgetData,
|
||||||
|
"monthly",
|
||||||
|
400000
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.daily.spentAmount).toBe(0);
|
expect(result.daily.spentAmount).toBe(0);
|
||||||
expect(result.weekly.spentAmount).toBe(0);
|
expect(result.weekly.spentAmount).toBe(0);
|
||||||
@@ -160,10 +244,14 @@ describe('budgetCalculation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculation accuracy', () => {
|
describe("calculation accuracy", () => {
|
||||||
it('maintains reasonable accuracy in conversions', () => {
|
it("maintains reasonable accuracy in conversions", () => {
|
||||||
const monthlyAmount = 435000; // Amount that should convert cleanly
|
const monthlyAmount = 435000; // Amount that should convert cleanly
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', monthlyAmount);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
monthlyAmount
|
||||||
|
);
|
||||||
|
|
||||||
const expectedWeekly = Math.round(monthlyAmount / 4.345);
|
const expectedWeekly = Math.round(monthlyAmount / 4.345);
|
||||||
const expectedDaily = Math.round(monthlyAmount / 30);
|
const expectedDaily = Math.round(monthlyAmount / 30);
|
||||||
@@ -172,13 +260,21 @@ describe('budgetCalculation', () => {
|
|||||||
const backToMonthlyFromWeekly = Math.round(expectedWeekly * 4.345);
|
const backToMonthlyFromWeekly = Math.round(expectedWeekly * 4.345);
|
||||||
const backToMonthlyFromDaily = Math.round(expectedDaily * 30);
|
const backToMonthlyFromDaily = Math.round(expectedDaily * 30);
|
||||||
|
|
||||||
expect(Math.abs(backToMonthlyFromWeekly - monthlyAmount)).toBeLessThan(100);
|
expect(Math.abs(backToMonthlyFromWeekly - monthlyAmount)).toBeLessThan(
|
||||||
expect(Math.abs(backToMonthlyFromDaily - monthlyAmount)).toBeLessThan(100);
|
100
|
||||||
|
);
|
||||||
|
expect(Math.abs(backToMonthlyFromDaily - monthlyAmount)).toBeLessThan(
|
||||||
|
100
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles rounding consistently', () => {
|
it("handles rounding consistently", () => {
|
||||||
// Test with amount that would create decimals
|
// Test with amount that would create decimals
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'weekly', 77777);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"weekly",
|
||||||
|
77777
|
||||||
|
);
|
||||||
|
|
||||||
expect(Number.isInteger(result.monthly.targetAmount)).toBe(true);
|
expect(Number.isInteger(result.monthly.targetAmount)).toBe(true);
|
||||||
expect(Number.isInteger(result.weekly.targetAmount)).toBe(true);
|
expect(Number.isInteger(result.weekly.targetAmount)).toBe(true);
|
||||||
@@ -186,45 +282,79 @@ describe('budgetCalculation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('budget period conversion consistency', () => {
|
describe("budget period conversion consistency", () => {
|
||||||
it('maintains consistency across different input types for same monthly equivalent', () => {
|
it("maintains consistency across different input types for same monthly equivalent", () => {
|
||||||
const monthlyAmount = 300000;
|
const monthlyAmount = 300000;
|
||||||
const weeklyEquivalent = Math.round(monthlyAmount / 4.345); // ~69043
|
const weeklyEquivalent = Math.round(monthlyAmount / 4.345); // ~69043
|
||||||
const dailyEquivalent = Math.round(monthlyAmount / 30); // 10000
|
const dailyEquivalent = Math.round(monthlyAmount / 30); // 10000
|
||||||
|
|
||||||
const fromMonthly = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', monthlyAmount);
|
const fromMonthly = calculateUpdatedBudgetData(
|
||||||
const fromWeekly = calculateUpdatedBudgetData(mockPrevBudgetData, 'weekly', weeklyEquivalent);
|
mockPrevBudgetData,
|
||||||
const fromDaily = calculateUpdatedBudgetData(mockPrevBudgetData, 'daily', dailyEquivalent);
|
"monthly",
|
||||||
|
monthlyAmount
|
||||||
|
);
|
||||||
|
const fromWeekly = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"weekly",
|
||||||
|
weeklyEquivalent
|
||||||
|
);
|
||||||
|
const fromDaily = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"daily",
|
||||||
|
dailyEquivalent
|
||||||
|
);
|
||||||
|
|
||||||
// All should result in similar monthly amounts (within rounding tolerance)
|
// All should result in similar monthly amounts (within rounding tolerance)
|
||||||
expect(Math.abs(fromMonthly.monthly.targetAmount - fromWeekly.monthly.targetAmount)).toBeLessThan(100);
|
expect(
|
||||||
expect(Math.abs(fromMonthly.monthly.targetAmount - fromDaily.monthly.targetAmount)).toBeLessThan(100);
|
Math.abs(
|
||||||
|
fromMonthly.monthly.targetAmount - fromWeekly.monthly.targetAmount
|
||||||
|
)
|
||||||
|
).toBeLessThan(100);
|
||||||
|
expect(
|
||||||
|
Math.abs(
|
||||||
|
fromMonthly.monthly.targetAmount - fromDaily.monthly.targetAmount
|
||||||
|
)
|
||||||
|
).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('data structure integrity', () => {
|
describe("data structure integrity", () => {
|
||||||
it('returns complete budget data structure', () => {
|
it("returns complete budget data structure", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 300000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
300000
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toHaveProperty('daily');
|
expect(result).toHaveProperty("daily");
|
||||||
expect(result).toHaveProperty('weekly');
|
expect(result).toHaveProperty("weekly");
|
||||||
expect(result).toHaveProperty('monthly');
|
expect(result).toHaveProperty("monthly");
|
||||||
|
|
||||||
['daily', 'weekly', 'monthly'].forEach(period => {
|
["daily", "weekly", "monthly"].forEach((period) => {
|
||||||
expect(result[period as keyof BudgetData]).toHaveProperty('targetAmount');
|
expect(result[period as keyof BudgetData]).toHaveProperty(
|
||||||
expect(result[period as keyof BudgetData]).toHaveProperty('spentAmount');
|
"targetAmount"
|
||||||
expect(result[period as keyof BudgetData]).toHaveProperty('remainingAmount');
|
);
|
||||||
|
expect(result[period as keyof BudgetData]).toHaveProperty(
|
||||||
|
"spentAmount"
|
||||||
|
);
|
||||||
|
expect(result[period as keyof BudgetData]).toHaveProperty(
|
||||||
|
"remainingAmount"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves data types correctly', () => {
|
it("preserves data types correctly", () => {
|
||||||
const result = calculateUpdatedBudgetData(mockPrevBudgetData, 'monthly', 300000);
|
const result = calculateUpdatedBudgetData(
|
||||||
|
mockPrevBudgetData,
|
||||||
|
"monthly",
|
||||||
|
300000
|
||||||
|
);
|
||||||
|
|
||||||
['daily', 'weekly', 'monthly'].forEach(period => {
|
["daily", "weekly", "monthly"].forEach((period) => {
|
||||||
const periodData = result[period as keyof BudgetData];
|
const periodData = result[period as keyof BudgetData];
|
||||||
expect(typeof periodData.targetAmount).toBe('number');
|
expect(typeof periodData.targetAmount).toBe("number");
|
||||||
expect(typeof periodData.spentAmount).toBe('number');
|
expect(typeof periodData.spentAmount).toBe("number");
|
||||||
expect(typeof periodData.remainingAmount).toBe('number');
|
expect(typeof periodData.remainingAmount).toBe("number");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export {
|
|||||||
useSignOutMutation,
|
useSignOutMutation,
|
||||||
useResetPasswordMutation,
|
useResetPasswordMutation,
|
||||||
useAuth,
|
useAuth,
|
||||||
} from './useAuthQueries';
|
} from "./useAuthQueries";
|
||||||
|
|
||||||
// 트랜잭션 관련 훅들
|
// 트랜잭션 관련 훅들
|
||||||
export {
|
export {
|
||||||
@@ -24,7 +24,7 @@ export {
|
|||||||
useDeleteTransactionMutation,
|
useDeleteTransactionMutation,
|
||||||
useTransactions,
|
useTransactions,
|
||||||
useTransactionStatsQuery,
|
useTransactionStatsQuery,
|
||||||
} from './useTransactionQueries';
|
} from "./useTransactionQueries";
|
||||||
|
|
||||||
// 동기화 관련 훅들
|
// 동기화 관련 훅들
|
||||||
export {
|
export {
|
||||||
@@ -35,7 +35,7 @@ export {
|
|||||||
useAutoSyncQuery,
|
useAutoSyncQuery,
|
||||||
useSync,
|
useSync,
|
||||||
useSyncSettings,
|
useSyncSettings,
|
||||||
} from './useSyncQueries';
|
} from "./useSyncQueries";
|
||||||
|
|
||||||
// 쿼리 클라이언트 설정 (재내보내기)
|
// 쿼리 클라이언트 설정 (재내보내기)
|
||||||
export {
|
export {
|
||||||
@@ -46,4 +46,4 @@ export {
|
|||||||
invalidateQueries,
|
invalidateQueries,
|
||||||
prefetchQueries,
|
prefetchQueries,
|
||||||
isDevMode,
|
isDevMode,
|
||||||
} from '@/lib/query/queryClient';
|
} from "@/lib/query/queryClient";
|
||||||
|
|||||||
@@ -5,18 +5,26 @@
|
|||||||
* 서버 상태 관리를 최적화합니다.
|
* 서버 상태 관리를 최적화합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
createSession,
|
createSession,
|
||||||
createAccount,
|
createAccount,
|
||||||
deleteCurrentSession,
|
deleteCurrentSession,
|
||||||
sendPasswordRecoveryEmail
|
sendPasswordRecoveryEmail,
|
||||||
} from '@/lib/appwrite/setup';
|
} from "@/lib/appwrite/setup";
|
||||||
import { queryKeys, queryConfigs, handleQueryError } from '@/lib/query/queryClient';
|
import {
|
||||||
import { authLogger } from '@/utils/logger';
|
queryKeys,
|
||||||
import { useAuthStore } from '@/stores';
|
queryConfigs,
|
||||||
import type { AuthResponse, SignUpResponse, ResetPasswordResponse } from '@/contexts/auth/types';
|
handleQueryError,
|
||||||
|
} from "@/lib/query/queryClient";
|
||||||
|
import { authLogger } from "@/utils/logger";
|
||||||
|
import { useAuthStore } from "@/stores";
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
SignUpResponse,
|
||||||
|
ResetPasswordResponse,
|
||||||
|
} from "@/contexts/auth/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 사용자 정보 조회 쿼리
|
* 현재 사용자 정보 조회 쿼리
|
||||||
@@ -31,14 +39,14 @@ export const useUserQuery = () => {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.auth.user(),
|
queryKey: queryKeys.auth.user(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
authLogger.info('사용자 정보 조회 시작');
|
authLogger.info("사용자 정보 조회 시작");
|
||||||
const result = await getCurrentUser();
|
const result = await getCurrentUser();
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw new Error(result.error.message);
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info('사용자 정보 조회 성공', { userId: result.user?.$id });
|
authLogger.info("사용자 정보 조회 성공", { userId: result.user?.$id });
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
...queryConfigs.userInfo,
|
...queryConfigs.userInfo,
|
||||||
@@ -48,7 +56,10 @@ export const useUserQuery = () => {
|
|||||||
|
|
||||||
// 에러 시 로그아웃 상태로 전환
|
// 에러 시 로그아웃 상태로 전환
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
if (
|
||||||
|
error?.message?.includes("401") ||
|
||||||
|
error?.message?.includes("Unauthorized")
|
||||||
|
) {
|
||||||
// 인증 에러는 재시도하지 않음
|
// 인증 에러는 재시도하지 않음
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -67,8 +78,8 @@ export const useUserQuery = () => {
|
|||||||
|
|
||||||
// 에러 시 스토어 정리
|
// 에러 시 스토어 정리
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
authLogger.error('사용자 정보 조회 실패:', error);
|
authLogger.error("사용자 정보 조회 실패:", error);
|
||||||
if (error?.message?.includes('401')) {
|
if (error?.message?.includes("401")) {
|
||||||
// 401 에러 시 로그아웃 처리
|
// 401 에러 시 로그아웃 처리
|
||||||
useAuthStore.getState().setUser(null);
|
useAuthStore.getState().setUser(null);
|
||||||
useAuthStore.getState().setSession(null);
|
useAuthStore.getState().setSession(null);
|
||||||
@@ -109,8 +120,14 @@ export const useSignInMutation = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ email, password }: { email: string; password: string }): Promise<AuthResponse> => {
|
mutationFn: async ({
|
||||||
authLogger.info('로그인 뮤테이션 시작', { email });
|
email,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<AuthResponse> => {
|
||||||
|
authLogger.info("로그인 뮤테이션 시작", { email });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionResult = await createSession(email, password);
|
const sessionResult = await createSession(email, password);
|
||||||
@@ -124,16 +141,24 @@ export const useSignInMutation = () => {
|
|||||||
const userResult = await getCurrentUser();
|
const userResult = await getCurrentUser();
|
||||||
|
|
||||||
if (userResult.user && userResult.session) {
|
if (userResult.user && userResult.session) {
|
||||||
authLogger.info('로그인 성공', { userId: userResult.user.$id });
|
authLogger.info("로그인 성공", { userId: userResult.user.$id });
|
||||||
return { user: userResult.user, error: null };
|
return { user: userResult.user, error: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: { message: '세션 또는 사용자 정보를 가져올 수 없습니다', code: 'AUTH_ERROR' } };
|
return {
|
||||||
|
error: {
|
||||||
|
message: "세션 또는 사용자 정보를 가져올 수 없습니다",
|
||||||
|
code: "AUTH_ERROR",
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : '로그인 중 알 수 없는 오류가 발생했습니다';
|
const errorMessage =
|
||||||
authLogger.error('로그인 에러:', error);
|
error instanceof Error
|
||||||
return { error: { message: errorMessage, code: 'UNKNOWN_ERROR' } };
|
? error.message
|
||||||
|
: "로그인 중 알 수 없는 오류가 발생했습니다";
|
||||||
|
authLogger.error("로그인 에러:", error);
|
||||||
|
return { error: { message: errorMessage, code: "UNKNOWN_ERROR" } };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -147,14 +172,14 @@ export const useSignInMutation = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth.user() });
|
queryClient.invalidateQueries({ queryKey: queryKeys.auth.user() });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() });
|
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() });
|
||||||
|
|
||||||
authLogger.info('로그인 뮤테이션 성공 - 쿼리 무효화 완료');
|
authLogger.info("로그인 뮤테이션 성공 - 쿼리 무효화 완료");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 처리
|
// 에러 시 처리
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const friendlyMessage = handleQueryError(error, '로그인');
|
const friendlyMessage = handleQueryError(error, "로그인");
|
||||||
authLogger.error('로그인 뮤테이션 실패:', friendlyMessage);
|
authLogger.error("로그인 뮤테이션 실패:", friendlyMessage);
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
useAuthStore.getState().setError(new Error(friendlyMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -168,13 +193,13 @@ export const useSignUpMutation = () => {
|
|||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
username
|
username,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
username: string;
|
username: string;
|
||||||
}): Promise<SignUpResponse> => {
|
}): Promise<SignUpResponse> => {
|
||||||
authLogger.info('회원가입 뮤테이션 시작', { email, username });
|
authLogger.info("회원가입 뮤테이션 시작", { email, username });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createAccount(email, password, username);
|
const result = await createAccount(email, password, username);
|
||||||
@@ -183,18 +208,24 @@ export const useSignUpMutation = () => {
|
|||||||
return { error: result.error, user: null };
|
return { error: result.error, user: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info('회원가입 성공', { userId: result.user?.$id });
|
authLogger.info("회원가입 성공", { userId: result.user?.$id });
|
||||||
return { error: null, user: result.user };
|
return { error: null, user: result.user };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : '회원가입 중 알 수 없는 오류가 발생했습니다';
|
const errorMessage =
|
||||||
authLogger.error('회원가입 에러:', error);
|
error instanceof Error
|
||||||
return { error: { message: errorMessage, code: 'UNKNOWN_ERROR' }, user: null };
|
? error.message
|
||||||
|
: "회원가입 중 알 수 없는 오류가 발생했습니다";
|
||||||
|
authLogger.error("회원가입 에러:", error);
|
||||||
|
return {
|
||||||
|
error: { message: errorMessage, code: "UNKNOWN_ERROR" },
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const friendlyMessage = handleQueryError(error, '회원가입');
|
const friendlyMessage = handleQueryError(error, "회원가입");
|
||||||
authLogger.error('회원가입 뮤테이션 실패:', friendlyMessage);
|
authLogger.error("회원가입 뮤테이션 실패:", friendlyMessage);
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
useAuthStore.getState().setError(new Error(friendlyMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -208,7 +239,7 @@ export const useSignOutMutation = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (): Promise<void> => {
|
mutationFn: async (): Promise<void> => {
|
||||||
authLogger.info('로그아웃 뮤테이션 시작');
|
authLogger.info("로그아웃 뮤테이션 시작");
|
||||||
await deleteCurrentSession();
|
await deleteCurrentSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -222,18 +253,18 @@ export const useSignOutMutation = () => {
|
|||||||
// 모든 쿼리 캐시 정리 (민감한 데이터 제거)
|
// 모든 쿼리 캐시 정리 (민감한 데이터 제거)
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
|
|
||||||
authLogger.info('로그아웃 성공 - 모든 캐시 정리 완료');
|
authLogger.info("로그아웃 성공 - 모든 캐시 정리 완료");
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시에도 로컬 상태는 정리
|
// 에러 시에도 로컬 상태는 정리
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
authLogger.error('로그아웃 에러:', error);
|
authLogger.error("로그아웃 에러:", error);
|
||||||
|
|
||||||
// 에러가 발생해도 로컬 상태는 정리
|
// 에러가 발생해도 로컬 상태는 정리
|
||||||
useAuthStore.getState().setSession(null);
|
useAuthStore.getState().setSession(null);
|
||||||
useAuthStore.getState().setUser(null);
|
useAuthStore.getState().setUser(null);
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, '로그아웃');
|
const friendlyMessage = handleQueryError(error, "로그아웃");
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
useAuthStore.getState().setError(new Error(friendlyMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -244,8 +275,12 @@ export const useSignOutMutation = () => {
|
|||||||
*/
|
*/
|
||||||
export const useResetPasswordMutation = () => {
|
export const useResetPasswordMutation = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ email }: { email: string }): Promise<ResetPasswordResponse> => {
|
mutationFn: async ({
|
||||||
authLogger.info('비밀번호 재설정 뮤테이션 시작', { email });
|
email,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
}): Promise<ResetPasswordResponse> => {
|
||||||
|
authLogger.info("비밀번호 재설정 뮤테이션 시작", { email });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendPasswordRecoveryEmail(email);
|
const result = await sendPasswordRecoveryEmail(email);
|
||||||
@@ -254,18 +289,21 @@ export const useResetPasswordMutation = () => {
|
|||||||
return { error: result.error };
|
return { error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info('비밀번호 재설정 이메일 발송 성공');
|
authLogger.info("비밀번호 재설정 이메일 발송 성공");
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : '비밀번호 재설정 중 오류가 발생했습니다';
|
const errorMessage =
|
||||||
authLogger.error('비밀번호 재설정 에러:', error);
|
error instanceof Error
|
||||||
return { error: { message: errorMessage, code: 'UNKNOWN_ERROR' } };
|
? error.message
|
||||||
|
: "비밀번호 재설정 중 오류가 발생했습니다";
|
||||||
|
authLogger.error("비밀번호 재설정 에러:", error);
|
||||||
|
return { error: { message: errorMessage, code: "UNKNOWN_ERROR" } };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const friendlyMessage = handleQueryError(error, '비밀번호 재설정');
|
const friendlyMessage = handleQueryError(error, "비밀번호 재설정");
|
||||||
authLogger.error('비밀번호 재설정 뮤테이션 실패:', friendlyMessage);
|
authLogger.error("비밀번호 재설정 뮤테이션 실패:", friendlyMessage);
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
useAuthStore.getState().setError(new Error(friendlyMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -291,7 +329,7 @@ export const useAuth = () => {
|
|||||||
session,
|
session,
|
||||||
loading: loading || userQuery.isLoading,
|
loading: loading || userQuery.isLoading,
|
||||||
error: error || userQuery.error,
|
error: error || userQuery.error,
|
||||||
appwriteInitialized: useAuthStore(state => state.appwriteInitialized),
|
appwriteInitialized: useAuthStore((state) => state.appwriteInitialized),
|
||||||
|
|
||||||
// 액션 (React Query 뮤테이션)
|
// 액션 (React Query 뮤테이션)
|
||||||
signIn: signInMutation.mutate,
|
signIn: signInMutation.mutate,
|
||||||
|
|||||||
@@ -5,13 +5,22 @@
|
|||||||
* 백그라운드 동기화와 상태 관리를 최적화합니다.
|
* 백그라운드 동기화와 상태 관리를 최적화합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { trySyncAllData, getLastSyncTime, setLastSyncTime } from '@/utils/syncUtils';
|
import {
|
||||||
import { queryKeys, queryConfigs, handleQueryError, invalidateQueries } from '@/lib/query/queryClient';
|
trySyncAllData,
|
||||||
import { syncLogger } from '@/utils/logger';
|
getLastSyncTime,
|
||||||
import { useAuthStore } from '@/stores';
|
setLastSyncTime,
|
||||||
import { toast } from '@/hooks/useToast.wrapper';
|
} from "@/utils/syncUtils";
|
||||||
import useNotifications from '@/hooks/useNotifications';
|
import {
|
||||||
|
queryKeys,
|
||||||
|
queryConfigs,
|
||||||
|
handleQueryError,
|
||||||
|
invalidateQueries,
|
||||||
|
} from "@/lib/query/queryClient";
|
||||||
|
import { syncLogger } from "@/utils/logger";
|
||||||
|
import { useAuthStore } from "@/stores";
|
||||||
|
import { toast } from "@/hooks/useToast.wrapper";
|
||||||
|
import useNotifications from "@/hooks/useNotifications";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마지막 동기화 시간 조회 쿼리
|
* 마지막 동기화 시간 조회 쿼리
|
||||||
@@ -21,7 +30,7 @@ export const useLastSyncTimeQuery = () => {
|
|||||||
queryKey: queryKeys.sync.lastSync(),
|
queryKey: queryKeys.sync.lastSync(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const lastSyncTime = getLastSyncTime();
|
const lastSyncTime = getLastSyncTime();
|
||||||
syncLogger.info('마지막 동기화 시간 조회', { lastSyncTime });
|
syncLogger.info("마지막 동기화 시간 조회", { lastSyncTime });
|
||||||
return lastSyncTime;
|
return lastSyncTime;
|
||||||
},
|
},
|
||||||
staleTime: 30 * 1000, // 30초
|
staleTime: 30 * 1000, // 30초
|
||||||
@@ -41,7 +50,7 @@ export const useSyncStatusQuery = () => {
|
|||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return {
|
return {
|
||||||
canSync: false,
|
canSync: false,
|
||||||
reason: '사용자 인증이 필요합니다.',
|
reason: "사용자 인증이 필요합니다.",
|
||||||
lastSyncTime: null,
|
lastSyncTime: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,25 +92,25 @@ export const useManualSyncMutation = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (): Promise<any> => {
|
mutationFn: async (): Promise<any> => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('수동 동기화 뮤테이션 시작', { userId: user.id });
|
syncLogger.info("수동 동기화 뮤테이션 시작", { userId: user.id });
|
||||||
|
|
||||||
// 동기화 실행
|
// 동기화 실행
|
||||||
const result = await trySyncAllData(user.id);
|
const result = await trySyncAllData(user.id);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '동기화에 실패했습니다.');
|
throw new Error(result.error || "동기화에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동기화 시간 업데이트
|
// 동기화 시간 업데이트
|
||||||
const currentTime = new Date().toISOString();
|
const currentTime = new Date().toISOString();
|
||||||
setLastSyncTime(currentTime);
|
setLastSyncTime(currentTime);
|
||||||
|
|
||||||
syncLogger.info('수동 동기화 성공', {
|
syncLogger.info("수동 동기화 성공", {
|
||||||
syncTime: currentTime,
|
syncTime: currentTime,
|
||||||
result
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...result, syncTime: currentTime };
|
return { ...result, syncTime: currentTime };
|
||||||
@@ -109,11 +118,8 @@ export const useManualSyncMutation = () => {
|
|||||||
|
|
||||||
// 뮤테이션 시작 시
|
// 뮤테이션 시작 시
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
syncLogger.info('동기화 시작 알림');
|
syncLogger.info("동기화 시작 알림");
|
||||||
addNotification(
|
addNotification("동기화 시작", "데이터 동기화가 시작되었습니다.");
|
||||||
"동기화 시작",
|
|
||||||
"데이터 동기화가 시작되었습니다."
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 성공 시 처리
|
// 성공 시 처리
|
||||||
@@ -135,13 +141,13 @@ export const useManualSyncMutation = () => {
|
|||||||
"모든 데이터가 성공적으로 동기화되었습니다."
|
"모든 데이터가 성공적으로 동기화되었습니다."
|
||||||
);
|
);
|
||||||
|
|
||||||
syncLogger.info('수동 동기화 뮤테이션 성공 완료', result);
|
syncLogger.info("수동 동기화 뮤테이션 성공 완료", result);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 처리
|
// 에러 시 처리
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const friendlyMessage = handleQueryError(error, '동기화');
|
const friendlyMessage = handleQueryError(error, "동기화");
|
||||||
syncLogger.error('수동 동기화 뮤테이션 실패:', friendlyMessage);
|
syncLogger.error("수동 동기화 뮤테이션 실패:", friendlyMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "동기화 실패",
|
title: "동기화 실패",
|
||||||
@@ -149,10 +155,7 @@ export const useManualSyncMutation = () => {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
|
||||||
addNotification(
|
addNotification("동기화 실패", friendlyMessage);
|
||||||
"동기화 실패",
|
|
||||||
friendlyMessage
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -171,22 +174,22 @@ export const useBackgroundSyncMutation = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (): Promise<any> => {
|
mutationFn: async (): Promise<any> => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('백그라운드 동기화 시작', { userId: user.id });
|
syncLogger.info("백그라운드 동기화 시작", { userId: user.id });
|
||||||
|
|
||||||
const result = await trySyncAllData(user.id);
|
const result = await trySyncAllData(user.id);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '백그라운드 동기화에 실패했습니다.');
|
throw new Error(result.error || "백그라운드 동기화에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = new Date().toISOString();
|
const currentTime = new Date().toISOString();
|
||||||
setLastSyncTime(currentTime);
|
setLastSyncTime(currentTime);
|
||||||
|
|
||||||
syncLogger.info('백그라운드 동기화 성공', {
|
syncLogger.info("백그라운드 동기화 성공", {
|
||||||
syncTime: currentTime
|
syncTime: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...result, syncTime: currentTime };
|
return { ...result, syncTime: currentTime };
|
||||||
@@ -201,12 +204,15 @@ export const useBackgroundSyncMutation = () => {
|
|||||||
// 동기화 시간 업데이트
|
// 동기화 시간 업데이트
|
||||||
queryClient.setQueryData(queryKeys.sync.lastSync(), result.syncTime);
|
queryClient.setQueryData(queryKeys.sync.lastSync(), result.syncTime);
|
||||||
|
|
||||||
syncLogger.info('백그라운드 동기화 완료 - 데이터 업데이트됨');
|
syncLogger.info("백그라운드 동기화 완료 - 데이터 업데이트됨");
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 조용히 로그만 남김
|
// 에러 시 조용히 로그만 남김
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
syncLogger.warn('백그라운드 동기화 실패 (조용히 처리됨):', error?.message);
|
syncLogger.warn(
|
||||||
|
"백그라운드 동기화 실패 (조용히 처리됨):",
|
||||||
|
error?.message
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -222,7 +228,7 @@ export const useAutoSyncQuery = (intervalMinutes: number = 5) => {
|
|||||||
const backgroundSyncMutation = useBackgroundSyncMutation();
|
const backgroundSyncMutation = useBackgroundSyncMutation();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['auto-sync', intervalMinutes],
|
queryKey: ["auto-sync", intervalMinutes],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -285,18 +291,18 @@ export const useSyncSettings = () => {
|
|||||||
|
|
||||||
// 자동 동기화 간격 설정 (localStorage 기반)
|
// 자동 동기화 간격 설정 (localStorage 기반)
|
||||||
const setAutoSyncInterval = (intervalMinutes: number) => {
|
const setAutoSyncInterval = (intervalMinutes: number) => {
|
||||||
localStorage.setItem('auto-sync-interval', intervalMinutes.toString());
|
localStorage.setItem("auto-sync-interval", intervalMinutes.toString());
|
||||||
|
|
||||||
// 관련 쿼리 무효화
|
// 관련 쿼리 무효화
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['auto-sync']
|
queryKey: ["auto-sync"],
|
||||||
});
|
});
|
||||||
|
|
||||||
syncLogger.info('자동 동기화 간격 설정됨', { intervalMinutes });
|
syncLogger.info("자동 동기화 간격 설정됨", { intervalMinutes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAutoSyncInterval = (): number => {
|
const getAutoSyncInterval = (): number => {
|
||||||
const stored = localStorage.getItem('auto-sync-interval');
|
const stored = localStorage.getItem("auto-sync-interval");
|
||||||
return stored ? parseInt(stored, 10) : 5; // 기본값 5분
|
return stored ? parseInt(stored, 10) : 5; // 기본값 5분
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,23 @@
|
|||||||
* 서버 상태 관리와 최적화된 캐싱을 제공합니다.
|
* 서버 상태 관리와 최적화된 캐싱을 제공합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getAllTransactions,
|
getAllTransactions,
|
||||||
saveTransaction,
|
saveTransaction,
|
||||||
updateExistingTransaction,
|
updateExistingTransaction,
|
||||||
deleteTransactionById
|
deleteTransactionById,
|
||||||
} from '@/lib/appwrite/setup';
|
} from "@/lib/appwrite/setup";
|
||||||
import { queryKeys, queryConfigs, handleQueryError, invalidateQueries } from '@/lib/query/queryClient';
|
import {
|
||||||
import { syncLogger } from '@/utils/logger';
|
queryKeys,
|
||||||
import { useAuthStore, useBudgetStore } from '@/stores';
|
queryConfigs,
|
||||||
import type { Transaction } from '@/contexts/budget/types';
|
handleQueryError,
|
||||||
import { toast } from '@/hooks/useToast.wrapper';
|
invalidateQueries,
|
||||||
|
} from "@/lib/query/queryClient";
|
||||||
|
import { syncLogger } from "@/utils/logger";
|
||||||
|
import { useAuthStore, useBudgetStore } from "@/stores";
|
||||||
|
import type { Transaction } from "@/contexts/budget/types";
|
||||||
|
import { toast } from "@/hooks/useToast.wrapper";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 트랜잭션 목록 조회 쿼리
|
* 트랜잭션 목록 조회 쿼리
|
||||||
@@ -32,18 +37,18 @@ export const useTransactionsQuery = (filters?: Record<string, any>) => {
|
|||||||
queryKey: queryKeys.transactions.list(filters),
|
queryKey: queryKeys.transactions.list(filters),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 목록 조회 시작', { userId: user.id, filters });
|
syncLogger.info("트랜잭션 목록 조회 시작", { userId: user.id, filters });
|
||||||
const result = await getAllTransactions(user.id);
|
const result = await getAllTransactions(user.id);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw new Error(result.error.message);
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 목록 조회 성공', {
|
syncLogger.info("트랜잭션 목록 조회 성공", {
|
||||||
count: result.transactions?.length || 0
|
count: result.transactions?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.transactions || [];
|
return result.transactions || [];
|
||||||
@@ -56,14 +61,14 @@ export const useTransactionsQuery = (filters?: Record<string, any>) => {
|
|||||||
// 성공 시 Zustand 스토어 동기화
|
// 성공 시 Zustand 스토어 동기화
|
||||||
onSuccess: (transactions) => {
|
onSuccess: (transactions) => {
|
||||||
useBudgetStore.getState().setTransactions(transactions);
|
useBudgetStore.getState().setTransactions(transactions);
|
||||||
syncLogger.info('Zustand 스토어 트랜잭션 동기화 완료', {
|
syncLogger.info("Zustand 스토어 트랜잭션 동기화 완료", {
|
||||||
count: transactions.length
|
count: transactions.length,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 처리
|
// 에러 시 처리
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
syncLogger.error('트랜잭션 목록 조회 실패:', error);
|
syncLogger.error("트랜잭션 목록 조회 실패:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -78,7 +83,7 @@ export const useTransactionQuery = (transactionId: string) => {
|
|||||||
queryKey: queryKeys.transactions.detail(transactionId),
|
queryKey: queryKeys.transactions.detail(transactionId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 트랜잭션을 가져와서 특정 ID 찾기
|
// 모든 트랜잭션을 가져와서 특정 ID 찾기
|
||||||
@@ -88,9 +93,11 @@ export const useTransactionQuery = (transactionId: string) => {
|
|||||||
throw new Error(result.error.message);
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = result.transactions?.find(t => t.id === transactionId);
|
const transaction = result.transactions?.find(
|
||||||
|
(t) => t.id === transactionId
|
||||||
|
);
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
throw new Error('트랜잭션을 찾을 수 없습니다.');
|
throw new Error("트랜잭션을 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return transaction;
|
return transaction;
|
||||||
@@ -112,15 +119,17 @@ export const useCreateTransactionMutation = () => {
|
|||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (transactionData: Omit<Transaction, 'id' | 'localTimestamp'>): Promise<Transaction> => {
|
mutationFn: async (
|
||||||
|
transactionData: Omit<Transaction, "id" | "localTimestamp">
|
||||||
|
): Promise<Transaction> => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 생성 뮤테이션 시작', {
|
syncLogger.info("트랜잭션 생성 뮤테이션 시작", {
|
||||||
amount: transactionData.amount,
|
amount: transactionData.amount,
|
||||||
category: transactionData.category,
|
category: transactionData.category,
|
||||||
type: transactionData.type
|
type: transactionData.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await saveTransaction({
|
const result = await saveTransaction({
|
||||||
@@ -133,12 +142,12 @@ export const useCreateTransactionMutation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.transaction) {
|
if (!result.transaction) {
|
||||||
throw new Error('트랜잭션 생성에 실패했습니다.');
|
throw new Error("트랜잭션 생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 생성 성공', {
|
syncLogger.info("트랜잭션 생성 성공", {
|
||||||
id: result.transaction.id,
|
id: result.transaction.id,
|
||||||
amount: result.transaction.amount
|
amount: result.transaction.amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.transaction;
|
return result.transaction;
|
||||||
@@ -147,10 +156,14 @@ export const useCreateTransactionMutation = () => {
|
|||||||
// 낙관적 업데이트
|
// 낙관적 업데이트
|
||||||
onMutate: async (newTransaction) => {
|
onMutate: async (newTransaction) => {
|
||||||
// 진행 중인 쿼리 취소
|
// 진행 중인 쿼리 취소
|
||||||
await queryClient.cancelQueries({ queryKey: queryKeys.transactions.all() });
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.transactions.all(),
|
||||||
|
});
|
||||||
|
|
||||||
// 이전 데이터 백업
|
// 이전 데이터 백업
|
||||||
const previousTransactions = queryClient.getQueryData(queryKeys.transactions.list()) as Transaction[] | undefined;
|
const previousTransactions = queryClient.getQueryData(
|
||||||
|
queryKeys.transactions.list()
|
||||||
|
) as Transaction[] | undefined;
|
||||||
|
|
||||||
// 낙관적으로 새 트랜잭션 추가
|
// 낙관적으로 새 트랜잭션 추가
|
||||||
if (previousTransactions) {
|
if (previousTransactions) {
|
||||||
@@ -160,10 +173,10 @@ export const useCreateTransactionMutation = () => {
|
|||||||
localTimestamp: new Date().toISOString(),
|
localTimestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(queryKeys.transactions.list(), [
|
||||||
queryKeys.transactions.list(),
|
...previousTransactions,
|
||||||
[...previousTransactions, optimisticTransaction]
|
optimisticTransaction,
|
||||||
);
|
]);
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
// Zustand 스토어에도 즉시 반영
|
||||||
useBudgetStore.getState().addTransaction(newTransaction);
|
useBudgetStore.getState().addTransaction(newTransaction);
|
||||||
@@ -180,21 +193,24 @@ export const useCreateTransactionMutation = () => {
|
|||||||
// 토스트 알림
|
// 토스트 알림
|
||||||
toast({
|
toast({
|
||||||
title: "트랜잭션 생성 완료",
|
title: "트랜잭션 생성 완료",
|
||||||
description: `${newTransaction.type === 'expense' ? '지출' : '수입'} ${newTransaction.amount.toLocaleString()}원이 추가되었습니다.`,
|
description: `${newTransaction.type === "expense" ? "지출" : "수입"} ${newTransaction.amount.toLocaleString()}원이 추가되었습니다.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 생성 뮤테이션 성공 완료');
|
syncLogger.info("트랜잭션 생성 뮤테이션 성공 완료");
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 롤백
|
// 에러 시 롤백
|
||||||
onError: (error: any, newTransaction, context) => {
|
onError: (error: any, newTransaction, context) => {
|
||||||
// 이전 데이터로 롤백
|
// 이전 데이터로 롤백
|
||||||
if (context?.previousTransactions) {
|
if (context?.previousTransactions) {
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), context.previousTransactions);
|
queryClient.setQueryData(
|
||||||
|
queryKeys.transactions.list(),
|
||||||
|
context.previousTransactions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, '트랜잭션 생성');
|
const friendlyMessage = handleQueryError(error, "트랜잭션 생성");
|
||||||
syncLogger.error('트랜잭션 생성 뮤테이션 실패:', friendlyMessage);
|
syncLogger.error("트랜잭션 생성 뮤테이션 실패:", friendlyMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "트랜잭션 생성 실패",
|
title: "트랜잭션 생성 실패",
|
||||||
@@ -213,14 +229,16 @@ export const useUpdateTransactionMutation = () => {
|
|||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (updatedTransaction: Transaction): Promise<Transaction> => {
|
mutationFn: async (
|
||||||
|
updatedTransaction: Transaction
|
||||||
|
): Promise<Transaction> => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 업데이트 뮤테이션 시작', {
|
syncLogger.info("트랜잭션 업데이트 뮤테이션 시작", {
|
||||||
id: updatedTransaction.id,
|
id: updatedTransaction.id,
|
||||||
amount: updatedTransaction.amount
|
amount: updatedTransaction.amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await updateExistingTransaction(updatedTransaction);
|
const result = await updateExistingTransaction(updatedTransaction);
|
||||||
@@ -230,11 +248,11 @@ export const useUpdateTransactionMutation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.transaction) {
|
if (!result.transaction) {
|
||||||
throw new Error('트랜잭션 업데이트에 실패했습니다.');
|
throw new Error("트랜잭션 업데이트에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 업데이트 성공', {
|
syncLogger.info("트랜잭션 업데이트 성공", {
|
||||||
id: result.transaction.id
|
id: result.transaction.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.transaction;
|
return result.transaction;
|
||||||
@@ -242,18 +260,28 @@ export const useUpdateTransactionMutation = () => {
|
|||||||
|
|
||||||
// 낙관적 업데이트
|
// 낙관적 업데이트
|
||||||
onMutate: async (updatedTransaction) => {
|
onMutate: async (updatedTransaction) => {
|
||||||
await queryClient.cancelQueries({ queryKey: queryKeys.transactions.all() });
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.transactions.all(),
|
||||||
|
});
|
||||||
|
|
||||||
const previousTransactions = queryClient.getQueryData(queryKeys.transactions.list()) as Transaction[] | undefined;
|
const previousTransactions = queryClient.getQueryData(
|
||||||
|
queryKeys.transactions.list()
|
||||||
|
) as Transaction[] | undefined;
|
||||||
|
|
||||||
if (previousTransactions) {
|
if (previousTransactions) {
|
||||||
const optimisticTransactions = previousTransactions.map(t =>
|
const optimisticTransactions = previousTransactions.map((t) =>
|
||||||
t.id === updatedTransaction.id
|
t.id === updatedTransaction.id
|
||||||
? { ...updatedTransaction, localTimestamp: new Date().toISOString() }
|
? {
|
||||||
|
...updatedTransaction,
|
||||||
|
localTimestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
: t
|
: t
|
||||||
);
|
);
|
||||||
|
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), optimisticTransactions);
|
queryClient.setQueryData(
|
||||||
|
queryKeys.transactions.list(),
|
||||||
|
optimisticTransactions
|
||||||
|
);
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
// Zustand 스토어에도 즉시 반영
|
||||||
useBudgetStore.getState().updateTransaction(updatedTransaction);
|
useBudgetStore.getState().updateTransaction(updatedTransaction);
|
||||||
@@ -273,17 +301,20 @@ export const useUpdateTransactionMutation = () => {
|
|||||||
description: "트랜잭션이 성공적으로 수정되었습니다.",
|
description: "트랜잭션이 성공적으로 수정되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 업데이트 뮤테이션 성공 완료');
|
syncLogger.info("트랜잭션 업데이트 뮤테이션 성공 완료");
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 롤백
|
// 에러 시 롤백
|
||||||
onError: (error: any, updatedTransaction, context) => {
|
onError: (error: any, updatedTransaction, context) => {
|
||||||
if (context?.previousTransactions) {
|
if (context?.previousTransactions) {
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), context.previousTransactions);
|
queryClient.setQueryData(
|
||||||
|
queryKeys.transactions.list(),
|
||||||
|
context.previousTransactions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, '트랜잭션 수정');
|
const friendlyMessage = handleQueryError(error, "트랜잭션 수정");
|
||||||
syncLogger.error('트랜잭션 업데이트 뮤테이션 실패:', friendlyMessage);
|
syncLogger.error("트랜잭션 업데이트 뮤테이션 실패:", friendlyMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "트랜잭션 수정 실패",
|
title: "트랜잭션 수정 실패",
|
||||||
@@ -304,10 +335,10 @@ export const useDeleteTransactionMutation = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (transactionId: string): Promise<void> => {
|
mutationFn: async (transactionId: string): Promise<void> => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 삭제 뮤테이션 시작', { id: transactionId });
|
syncLogger.info("트랜잭션 삭제 뮤테이션 시작", { id: transactionId });
|
||||||
|
|
||||||
const result = await deleteTransactionById(transactionId);
|
const result = await deleteTransactionById(transactionId);
|
||||||
|
|
||||||
@@ -315,18 +346,27 @@ export const useDeleteTransactionMutation = () => {
|
|||||||
throw new Error(result.error.message);
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 삭제 성공', { id: transactionId });
|
syncLogger.info("트랜잭션 삭제 성공", { id: transactionId });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 낙관적 업데이트
|
// 낙관적 업데이트
|
||||||
onMutate: async (transactionId) => {
|
onMutate: async (transactionId) => {
|
||||||
await queryClient.cancelQueries({ queryKey: queryKeys.transactions.all() });
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.transactions.all(),
|
||||||
|
});
|
||||||
|
|
||||||
const previousTransactions = queryClient.getQueryData(queryKeys.transactions.list()) as Transaction[] | undefined;
|
const previousTransactions = queryClient.getQueryData(
|
||||||
|
queryKeys.transactions.list()
|
||||||
|
) as Transaction[] | undefined;
|
||||||
|
|
||||||
if (previousTransactions) {
|
if (previousTransactions) {
|
||||||
const optimisticTransactions = previousTransactions.filter(t => t.id !== transactionId);
|
const optimisticTransactions = previousTransactions.filter(
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), optimisticTransactions);
|
(t) => t.id !== transactionId
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.transactions.list(),
|
||||||
|
optimisticTransactions
|
||||||
|
);
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
// Zustand 스토어에도 즉시 반영
|
||||||
useBudgetStore.getState().deleteTransaction(transactionId);
|
useBudgetStore.getState().deleteTransaction(transactionId);
|
||||||
@@ -345,17 +385,20 @@ export const useDeleteTransactionMutation = () => {
|
|||||||
description: "트랜잭션이 성공적으로 삭제되었습니다.",
|
description: "트랜잭션이 성공적으로 삭제되었습니다.",
|
||||||
});
|
});
|
||||||
|
|
||||||
syncLogger.info('트랜잭션 삭제 뮤테이션 성공 완료');
|
syncLogger.info("트랜잭션 삭제 뮤테이션 성공 완료");
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에러 시 롤백
|
// 에러 시 롤백
|
||||||
onError: (error: any, transactionId, context) => {
|
onError: (error: any, transactionId, context) => {
|
||||||
if (context?.previousTransactions) {
|
if (context?.previousTransactions) {
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), context.previousTransactions);
|
queryClient.setQueryData(
|
||||||
|
queryKeys.transactions.list(),
|
||||||
|
context.previousTransactions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, '트랜잭션 삭제');
|
const friendlyMessage = handleQueryError(error, "트랜잭션 삭제");
|
||||||
syncLogger.error('트랜잭션 삭제 뮤테이션 실패:', friendlyMessage);
|
syncLogger.error("트랜잭션 삭제 뮤테이션 실패:", friendlyMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "트랜잭션 삭제 실패",
|
title: "트랜잭션 삭제 실패",
|
||||||
@@ -417,7 +460,7 @@ export const useTransactionStatsQuery = () => {
|
|||||||
queryKey: queryKeys.budget.stats(),
|
queryKey: queryKeys.budget.stats(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('사용자 인증이 필요합니다.');
|
throw new Error("사용자 인증이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getAllTransactions(user.id);
|
const result = await getAllTransactions(user.id);
|
||||||
@@ -430,11 +473,11 @@ export const useTransactionStatsQuery = () => {
|
|||||||
|
|
||||||
// 통계 계산
|
// 통계 계산
|
||||||
const totalExpenses = transactions
|
const totalExpenses = transactions
|
||||||
.filter(t => t.type === 'expense')
|
.filter((t) => t.type === "expense")
|
||||||
.reduce((sum, t) => sum + t.amount, 0);
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
.filter(t => t.type === 'income')
|
.filter((t) => t.type === "income")
|
||||||
.reduce((sum, t) => sum + t.amount, 0);
|
.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
|
||||||
const balance = totalIncome - totalExpenses;
|
const balance = totalIncome - totalExpenses;
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export const useManualSync = (user: Models.User<Models.Preferences> | null) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
syncing,
|
syncing,
|
||||||
handleManualSync
|
handleManualSync,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest';
|
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
|
||||||
import {
|
import {
|
||||||
MONTHS_KR,
|
MONTHS_KR,
|
||||||
isValidMonth,
|
isValidMonth,
|
||||||
getCurrentMonth,
|
getCurrentMonth,
|
||||||
getPrevMonth,
|
getPrevMonth,
|
||||||
getNextMonth,
|
getNextMonth,
|
||||||
formatMonthForDisplay
|
formatMonthForDisplay,
|
||||||
} from '../dateUtils';
|
} from "../dateUtils";
|
||||||
|
|
||||||
// Mock logger to prevent console output during tests
|
// Mock logger to prevent console output during tests
|
||||||
vi.mock('@/utils/logger', () => ({
|
vi.mock("@/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('dateUtils', () => {
|
describe("dateUtils", () => {
|
||||||
// Mock current date for consistent testing
|
// Mock current date for consistent testing
|
||||||
const mockDate = new Date('2024-06-15T12:00:00.000Z');
|
const mockDate = new Date("2024-06-15T12:00:00.000Z");
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -29,144 +29,154 @@ describe('dateUtils', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MONTHS_KR', () => {
|
describe("MONTHS_KR", () => {
|
||||||
it('contains all 12 months in Korean', () => {
|
it("contains all 12 months in Korean", () => {
|
||||||
expect(MONTHS_KR).toHaveLength(12);
|
expect(MONTHS_KR).toHaveLength(12);
|
||||||
expect(MONTHS_KR[0]).toBe('1월');
|
expect(MONTHS_KR[0]).toBe("1월");
|
||||||
expect(MONTHS_KR[11]).toBe('12월');
|
expect(MONTHS_KR[11]).toBe("12월");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has correct month names', () => {
|
it("has correct month names", () => {
|
||||||
const expectedMonths = [
|
const expectedMonths = [
|
||||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
"1월",
|
||||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
"2월",
|
||||||
|
"3월",
|
||||||
|
"4월",
|
||||||
|
"5월",
|
||||||
|
"6월",
|
||||||
|
"7월",
|
||||||
|
"8월",
|
||||||
|
"9월",
|
||||||
|
"10월",
|
||||||
|
"11월",
|
||||||
|
"12월",
|
||||||
];
|
];
|
||||||
expect(MONTHS_KR).toEqual(expectedMonths);
|
expect(MONTHS_KR).toEqual(expectedMonths);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isValidMonth', () => {
|
describe("isValidMonth", () => {
|
||||||
it('validates correct YYYY-MM format', () => {
|
it("validates correct YYYY-MM format", () => {
|
||||||
expect(isValidMonth('2024-01')).toBe(true);
|
expect(isValidMonth("2024-01")).toBe(true);
|
||||||
expect(isValidMonth('2024-12')).toBe(true);
|
expect(isValidMonth("2024-12")).toBe(true);
|
||||||
expect(isValidMonth('2023-06')).toBe(true);
|
expect(isValidMonth("2023-06")).toBe(true);
|
||||||
expect(isValidMonth('2025-09')).toBe(true);
|
expect(isValidMonth("2025-09")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid month numbers', () => {
|
it("rejects invalid month numbers", () => {
|
||||||
expect(isValidMonth('2024-00')).toBe(false);
|
expect(isValidMonth("2024-00")).toBe(false);
|
||||||
expect(isValidMonth('2024-13')).toBe(false);
|
expect(isValidMonth("2024-13")).toBe(false);
|
||||||
expect(isValidMonth('2024-99')).toBe(false);
|
expect(isValidMonth("2024-99")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid formats', () => {
|
it("rejects invalid formats", () => {
|
||||||
expect(isValidMonth('24-01')).toBe(false);
|
expect(isValidMonth("24-01")).toBe(false);
|
||||||
expect(isValidMonth('2024-1')).toBe(false);
|
expect(isValidMonth("2024-1")).toBe(false);
|
||||||
expect(isValidMonth('2024/01')).toBe(false);
|
expect(isValidMonth("2024/01")).toBe(false);
|
||||||
expect(isValidMonth('2024.01')).toBe(false);
|
expect(isValidMonth("2024.01")).toBe(false);
|
||||||
expect(isValidMonth('2024-01-01')).toBe(false);
|
expect(isValidMonth("2024-01-01")).toBe(false);
|
||||||
expect(isValidMonth('')).toBe(false);
|
expect(isValidMonth("")).toBe(false);
|
||||||
expect(isValidMonth('invalid')).toBe(false);
|
expect(isValidMonth("invalid")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge cases', () => {
|
it("handles edge cases", () => {
|
||||||
expect(isValidMonth('0000-01')).toBe(true); // 기술적으로 valid
|
expect(isValidMonth("0000-01")).toBe(true); // 기술적으로 valid
|
||||||
expect(isValidMonth('9999-12')).toBe(true);
|
expect(isValidMonth("9999-12")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCurrentMonth', () => {
|
describe("getCurrentMonth", () => {
|
||||||
it('returns current month in YYYY-MM format', () => {
|
it("returns current month in YYYY-MM format", () => {
|
||||||
expect(getCurrentMonth()).toBe('2024-06');
|
expect(getCurrentMonth()).toBe("2024-06");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPrevMonth', () => {
|
describe("getPrevMonth", () => {
|
||||||
it('calculates previous month correctly', () => {
|
it("calculates previous month correctly", () => {
|
||||||
expect(getPrevMonth('2024-06')).toBe('2024-05');
|
expect(getPrevMonth("2024-06")).toBe("2024-05");
|
||||||
expect(getPrevMonth('2024-03')).toBe('2024-02');
|
expect(getPrevMonth("2024-03")).toBe("2024-02");
|
||||||
expect(getPrevMonth('2024-12')).toBe('2024-11');
|
expect(getPrevMonth("2024-12")).toBe("2024-11");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles year boundary correctly', () => {
|
it("handles year boundary correctly", () => {
|
||||||
expect(getPrevMonth('2024-01')).toBe('2023-12');
|
expect(getPrevMonth("2024-01")).toBe("2023-12");
|
||||||
expect(getPrevMonth('2025-01')).toBe('2024-12');
|
expect(getPrevMonth("2025-01")).toBe("2024-12");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invalid input gracefully', () => {
|
it("handles invalid input gracefully", () => {
|
||||||
expect(getPrevMonth('invalid')).toBe('2024-06'); // current month fallback
|
expect(getPrevMonth("invalid")).toBe("2024-06"); // current month fallback
|
||||||
expect(getPrevMonth('')).toBe('2024-06');
|
expect(getPrevMonth("")).toBe("2024-06");
|
||||||
expect(getPrevMonth('2024-13')).toBe('2024-06');
|
expect(getPrevMonth("2024-13")).toBe("2024-06");
|
||||||
expect(getPrevMonth('24-01')).toBe('2024-06');
|
expect(getPrevMonth("24-01")).toBe("2024-06");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge cases', () => {
|
it("handles edge cases", () => {
|
||||||
expect(getPrevMonth('0001-01')).toBe('0001-12'); // date-fns handles year 0 differently
|
expect(getPrevMonth("0001-01")).toBe("0001-12"); // date-fns handles year 0 differently
|
||||||
expect(getPrevMonth('2024-00')).toBe('2024-06'); // invalid, returns current
|
expect(getPrevMonth("2024-00")).toBe("2024-06"); // invalid, returns current
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getNextMonth', () => {
|
describe("getNextMonth", () => {
|
||||||
it('calculates next month correctly', () => {
|
it("calculates next month correctly", () => {
|
||||||
expect(getNextMonth('2024-06')).toBe('2024-07');
|
expect(getNextMonth("2024-06")).toBe("2024-07");
|
||||||
expect(getNextMonth('2024-03')).toBe('2024-04');
|
expect(getNextMonth("2024-03")).toBe("2024-04");
|
||||||
expect(getNextMonth('2024-11')).toBe('2024-12');
|
expect(getNextMonth("2024-11")).toBe("2024-12");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles year boundary correctly', () => {
|
it("handles year boundary correctly", () => {
|
||||||
expect(getNextMonth('2024-12')).toBe('2025-01');
|
expect(getNextMonth("2024-12")).toBe("2025-01");
|
||||||
expect(getNextMonth('2023-12')).toBe('2024-01');
|
expect(getNextMonth("2023-12")).toBe("2024-01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invalid input gracefully', () => {
|
it("handles invalid input gracefully", () => {
|
||||||
expect(getNextMonth('invalid')).toBe('2024-06'); // current month fallback
|
expect(getNextMonth("invalid")).toBe("2024-06"); // current month fallback
|
||||||
expect(getNextMonth('')).toBe('2024-06');
|
expect(getNextMonth("")).toBe("2024-06");
|
||||||
expect(getNextMonth('2024-13')).toBe('2024-06');
|
expect(getNextMonth("2024-13")).toBe("2024-06");
|
||||||
expect(getNextMonth('24-01')).toBe('2024-06');
|
expect(getNextMonth("24-01")).toBe("2024-06");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge cases', () => {
|
it("handles edge cases", () => {
|
||||||
expect(getNextMonth('9999-12')).toBe('10000-01'); // theoretically valid
|
expect(getNextMonth("9999-12")).toBe("10000-01"); // theoretically valid
|
||||||
expect(getNextMonth('2024-00')).toBe('2024-06'); // invalid, returns current
|
expect(getNextMonth("2024-00")).toBe("2024-06"); // invalid, returns current
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatMonthForDisplay', () => {
|
describe("formatMonthForDisplay", () => {
|
||||||
it('formats valid months correctly', () => {
|
it("formats valid months correctly", () => {
|
||||||
expect(formatMonthForDisplay('2024-01')).toBe('2024년 01월');
|
expect(formatMonthForDisplay("2024-01")).toBe("2024년 01월");
|
||||||
expect(formatMonthForDisplay('2024-06')).toBe('2024년 06월');
|
expect(formatMonthForDisplay("2024-06")).toBe("2024년 06월");
|
||||||
expect(formatMonthForDisplay('2024-12')).toBe('2024년 12월');
|
expect(formatMonthForDisplay("2024-12")).toBe("2024년 12월");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles different years', () => {
|
it("handles different years", () => {
|
||||||
expect(formatMonthForDisplay('2023-03')).toBe('2023년 03월');
|
expect(formatMonthForDisplay("2023-03")).toBe("2023년 03월");
|
||||||
expect(formatMonthForDisplay('2025-09')).toBe('2025년 09월');
|
expect(formatMonthForDisplay("2025-09")).toBe("2025년 09월");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invalid input gracefully', () => {
|
it("handles invalid input gracefully", () => {
|
||||||
// Should return current date formatted when invalid input
|
// Should return current date formatted when invalid input
|
||||||
expect(formatMonthForDisplay('invalid')).toBe('2024년 06월');
|
expect(formatMonthForDisplay("invalid")).toBe("2024년 06월");
|
||||||
expect(formatMonthForDisplay('')).toBe('2024년 06월');
|
expect(formatMonthForDisplay("")).toBe("2024년 06월");
|
||||||
expect(formatMonthForDisplay('2024-13')).toBe('2024년 06월');
|
expect(formatMonthForDisplay("2024-13")).toBe("2024년 06월");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves original format on error', () => {
|
it("preserves original format on error", () => {
|
||||||
// For some edge cases, it might return the original string
|
// For some edge cases, it might return the original string
|
||||||
const result = formatMonthForDisplay('completely-invalid-format');
|
const result = formatMonthForDisplay("completely-invalid-format");
|
||||||
// Could be either the fallback format or the original string
|
// Could be either the fallback format or the original string
|
||||||
expect(typeof result).toBe('string');
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles edge case years', () => {
|
it("handles edge case years", () => {
|
||||||
expect(formatMonthForDisplay('0001-01')).toBe('0001년 01월');
|
expect(formatMonthForDisplay("0001-01")).toBe("0001년 01월");
|
||||||
expect(formatMonthForDisplay('9999-12')).toBe('9999년 12월');
|
expect(formatMonthForDisplay("9999-12")).toBe("9999년 12월");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('month navigation sequences', () => {
|
describe("month navigation sequences", () => {
|
||||||
it('maintains consistency in forward/backward navigation', () => {
|
it("maintains consistency in forward/backward navigation", () => {
|
||||||
const startMonth = '2024-06';
|
const startMonth = "2024-06";
|
||||||
|
|
||||||
// Forward then backward should return to original
|
// Forward then backward should return to original
|
||||||
const nextMonth = getNextMonth(startMonth);
|
const nextMonth = getNextMonth(startMonth);
|
||||||
@@ -179,20 +189,20 @@ describe('dateUtils', () => {
|
|||||||
expect(backToNext).toBe(startMonth);
|
expect(backToNext).toBe(startMonth);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple month navigation', () => {
|
it("handles multiple month navigation", () => {
|
||||||
let month = '2024-01';
|
let month = "2024-01";
|
||||||
|
|
||||||
// Navigate forward 12 months
|
// Navigate forward 12 months
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
month = getNextMonth(month);
|
month = getNextMonth(month);
|
||||||
}
|
}
|
||||||
expect(month).toBe('2025-01');
|
expect(month).toBe("2025-01");
|
||||||
|
|
||||||
// Navigate backward 12 months
|
// Navigate backward 12 months
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
month = getPrevMonth(month);
|
month = getPrevMonth(month);
|
||||||
}
|
}
|
||||||
expect(month).toBe('2024-01');
|
expect(month).toBe("2024-01");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { ID, Query, Permission, Role, Models } from "appwrite";
|
import { ID, Query, Permission, Role, Models } from "appwrite";
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
import { appwriteLogger } from "@/utils/logger";
|
||||||
import { databases, account, getInitializationStatus, reinitializeAppwriteClient } from "./client";
|
import {
|
||||||
|
databases,
|
||||||
|
account,
|
||||||
|
getInitializationStatus,
|
||||||
|
reinitializeAppwriteClient,
|
||||||
|
} from "./client";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import type { ApiError } from "@/types/common";
|
import type { ApiError } from "@/types/common";
|
||||||
|
|
||||||
@@ -199,8 +204,8 @@ export const createSession = async (email: string, password: string) => {
|
|||||||
session: null,
|
session: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "로그인에 실패했습니다.",
|
message: error.message || "로그인에 실패했습니다.",
|
||||||
code: error.code || "AUTH_ERROR"
|
code: error.code || "AUTH_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -208,7 +213,11 @@ export const createSession = async (email: string, password: string) => {
|
|||||||
/**
|
/**
|
||||||
* 계정 생성 (회원가입)
|
* 계정 생성 (회원가입)
|
||||||
*/
|
*/
|
||||||
export const createAccount = async (email: string, password: string, username: string) => {
|
export const createAccount = async (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
username: string
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const user = await account.create(ID.unique(), email, password, username);
|
const user = await account.create(ID.unique(), email, password, username);
|
||||||
return { user, error: null };
|
return { user, error: null };
|
||||||
@@ -218,8 +227,8 @@ export const createAccount = async (email: string, password: string, username: s
|
|||||||
user: null,
|
user: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "회원가입에 실패했습니다.",
|
message: error.message || "회원가입에 실패했습니다.",
|
||||||
code: error.code || "SIGNUP_ERROR"
|
code: error.code || "SIGNUP_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -229,7 +238,7 @@ export const createAccount = async (email: string, password: string, username: s
|
|||||||
*/
|
*/
|
||||||
export const deleteCurrentSession = async () => {
|
export const deleteCurrentSession = async () => {
|
||||||
try {
|
try {
|
||||||
await account.deleteSession('current');
|
await account.deleteSession("current");
|
||||||
appwriteLogger.info("로그아웃 완료");
|
appwriteLogger.info("로그아웃 완료");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("로그아웃 실패:", error);
|
appwriteLogger.error("로그아웃 실패:", error);
|
||||||
@@ -243,7 +252,7 @@ export const deleteCurrentSession = async () => {
|
|||||||
export const getCurrentUser = async () => {
|
export const getCurrentUser = async () => {
|
||||||
try {
|
try {
|
||||||
const user = await account.get();
|
const user = await account.get();
|
||||||
const session = await account.getSession('current');
|
const session = await account.getSession("current");
|
||||||
return { user, session, error: null };
|
return { user, session, error: null };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.debug("사용자 정보 가져오기 실패:", error);
|
appwriteLogger.debug("사용자 정보 가져오기 실패:", error);
|
||||||
@@ -252,8 +261,8 @@ export const getCurrentUser = async () => {
|
|||||||
session: null,
|
session: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "사용자 정보를 가져올 수 없습니다.",
|
message: error.message || "사용자 정보를 가져올 수 없습니다.",
|
||||||
code: error.code || "USER_ERROR"
|
code: error.code || "USER_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -263,15 +272,18 @@ export const getCurrentUser = async () => {
|
|||||||
*/
|
*/
|
||||||
export const sendPasswordRecoveryEmail = async (email: string) => {
|
export const sendPasswordRecoveryEmail = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
await account.createRecovery(email, window.location.origin + "/reset-password");
|
await account.createRecovery(
|
||||||
|
email,
|
||||||
|
window.location.origin + "/reset-password"
|
||||||
|
);
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("비밀번호 재설정 이메일 발송 실패:", error);
|
appwriteLogger.error("비밀번호 재설정 이메일 발송 실패:", error);
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "비밀번호 재설정 이메일 발송에 실패했습니다.",
|
message: error.message || "비밀번호 재설정 이메일 발송에 실패했습니다.",
|
||||||
code: error.code || "RECOVERY_ERROR"
|
code: error.code || "RECOVERY_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -310,12 +322,12 @@ export const getAllTransactions = async (userId: string) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 목록 조회 성공", {
|
appwriteLogger.info("트랜잭션 목록 조회 성공", {
|
||||||
count: transactions.length
|
count: transactions.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactions,
|
transactions,
|
||||||
error: null
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("트랜잭션 목록 조회 실패:", error);
|
appwriteLogger.error("트랜잭션 목록 조회 실패:", error);
|
||||||
@@ -323,8 +335,8 @@ export const getAllTransactions = async (userId: string) => {
|
|||||||
transactions: null,
|
transactions: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "트랜잭션 목록을 불러올 수 없습니다.",
|
message: error.message || "트랜잭션 목록을 불러올 수 없습니다.",
|
||||||
code: error.code || "FETCH_ERROR"
|
code: error.code || "FETCH_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -339,7 +351,7 @@ export const saveTransaction = async (transactionData: any) => {
|
|||||||
|
|
||||||
appwriteLogger.info("트랜잭션 저장 시작", {
|
appwriteLogger.info("트랜잭션 저장 시작", {
|
||||||
amount: transactionData.amount,
|
amount: transactionData.amount,
|
||||||
type: transactionData.type
|
type: transactionData.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = {
|
const documentData = {
|
||||||
@@ -375,12 +387,12 @@ export const saveTransaction = async (transactionData: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 저장 성공", {
|
appwriteLogger.info("트랜잭션 저장 성공", {
|
||||||
id: transaction.id
|
id: transaction.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transaction,
|
transaction,
|
||||||
error: null
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("트랜잭션 저장 실패:", error);
|
appwriteLogger.error("트랜잭션 저장 실패:", error);
|
||||||
@@ -388,8 +400,8 @@ export const saveTransaction = async (transactionData: any) => {
|
|||||||
transaction: null,
|
transaction: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "트랜잭션 저장에 실패했습니다.",
|
message: error.message || "트랜잭션 저장에 실패했습니다.",
|
||||||
code: error.code || "SAVE_ERROR"
|
code: error.code || "SAVE_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -403,17 +415,14 @@ export const updateExistingTransaction = async (transactionData: any) => {
|
|||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
const transactionsCollectionId = config.transactionsCollectionId;
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 업데이트 시작", {
|
appwriteLogger.info("트랜잭션 업데이트 시작", {
|
||||||
id: transactionData.id
|
id: transactionData.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 먼저 해당 트랜잭션 문서 찾기
|
// 먼저 해당 트랜잭션 문서 찾기
|
||||||
const existingResponse = await databases.listDocuments(
|
const existingResponse = await databases.listDocuments(
|
||||||
databaseId,
|
databaseId,
|
||||||
transactionsCollectionId,
|
transactionsCollectionId,
|
||||||
[
|
[Query.equal("transaction_id", transactionData.id), Query.limit(1)]
|
||||||
Query.equal("transaction_id", transactionData.id),
|
|
||||||
Query.limit(1),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingResponse.documents.length === 0) {
|
if (existingResponse.documents.length === 0) {
|
||||||
@@ -453,12 +462,12 @@ export const updateExistingTransaction = async (transactionData: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 업데이트 성공", {
|
appwriteLogger.info("트랜잭션 업데이트 성공", {
|
||||||
id: transaction.id
|
id: transaction.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transaction,
|
transaction,
|
||||||
error: null
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("트랜잭션 업데이트 실패:", error);
|
appwriteLogger.error("트랜잭션 업데이트 실패:", error);
|
||||||
@@ -466,8 +475,8 @@ export const updateExistingTransaction = async (transactionData: any) => {
|
|||||||
transaction: null,
|
transaction: null,
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "트랜잭션 업데이트에 실패했습니다.",
|
message: error.message || "트랜잭션 업데이트에 실패했습니다.",
|
||||||
code: error.code || "UPDATE_ERROR"
|
code: error.code || "UPDATE_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -486,10 +495,7 @@ export const deleteTransactionById = async (transactionId: string) => {
|
|||||||
const existingResponse = await databases.listDocuments(
|
const existingResponse = await databases.listDocuments(
|
||||||
databaseId,
|
databaseId,
|
||||||
transactionsCollectionId,
|
transactionsCollectionId,
|
||||||
[
|
[Query.equal("transaction_id", transactionId), Query.limit(1)]
|
||||||
Query.equal("transaction_id", transactionId),
|
|
||||||
Query.limit(1),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingResponse.documents.length === 0) {
|
if (existingResponse.documents.length === 0) {
|
||||||
@@ -507,15 +513,15 @@ export const deleteTransactionById = async (transactionId: string) => {
|
|||||||
appwriteLogger.info("트랜잭션 삭제 성공", { id: transactionId });
|
appwriteLogger.info("트랜잭션 삭제 성공", { id: transactionId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: null
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appwriteLogger.error("트랜잭션 삭제 실패:", error);
|
appwriteLogger.error("트랜잭션 삭제 실패:", error);
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
message: error.message || "트랜잭션 삭제에 실패했습니다.",
|
message: error.message || "트랜잭션 삭제에 실패했습니다.",
|
||||||
code: error.code || "DELETE_ERROR"
|
code: error.code || "DELETE_ERROR",
|
||||||
} as ApiError
|
} as ApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* 애플리케이션 전체에서 사용할 QueryClient 설정 및 기본 옵션을 정의합니다.
|
* 애플리케이션 전체에서 사용할 QueryClient 설정 및 기본 옵션을 정의합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { syncLogger } from '@/utils/logger';
|
import { syncLogger } from "@/utils/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryClient 기본 설정
|
* QueryClient 기본 설정
|
||||||
@@ -43,7 +43,7 @@ export const queryClient = new QueryClient({
|
|||||||
// 재시도 설정 (지수 백오프 사용)
|
// 재시도 설정 (지수 백오프 사용)
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
// 네트워크 에러나 서버 에러인 경우에만 재시도
|
// 네트워크 에러나 서버 에러인 경우에만 재시도
|
||||||
if (error?.code === 'NETWORK_ERROR' || error?.status >= 500) {
|
if (error?.code === "NETWORK_ERROR" || error?.status >= 500) {
|
||||||
return failureCount < 3;
|
return failureCount < 3;
|
||||||
}
|
}
|
||||||
// 클라이언트 에러 (400번대)는 재시도하지 않음
|
// 클라이언트 에러 (400번대)는 재시도하지 않음
|
||||||
@@ -56,7 +56,7 @@ export const queryClient = new QueryClient({
|
|||||||
mutations: {
|
mutations: {
|
||||||
// 뮤테이션 실패 시 재시도 (네트워크 에러인 경우만)
|
// 뮤테이션 실패 시 재시도 (네트워크 에러인 경우만)
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
if (error?.code === 'NETWORK_ERROR') {
|
if (error?.code === "NETWORK_ERROR") {
|
||||||
return failureCount < 2;
|
return failureCount < 2;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -76,33 +76,35 @@ export const queryClient = new QueryClient({
|
|||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
// 인증 관련
|
// 인증 관련
|
||||||
auth: {
|
auth: {
|
||||||
user: () => ['auth', 'user'] as const,
|
user: () => ["auth", "user"] as const,
|
||||||
session: () => ['auth', 'session'] as const,
|
session: () => ["auth", "session"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 거래 관련
|
// 거래 관련
|
||||||
transactions: {
|
transactions: {
|
||||||
all: () => ['transactions'] as const,
|
all: () => ["transactions"] as const,
|
||||||
lists: () => [...queryKeys.transactions.all(), 'list'] as const,
|
lists: () => [...queryKeys.transactions.all(), "list"] as const,
|
||||||
list: (filters?: Record<string, any>) => [...queryKeys.transactions.lists(), filters] as const,
|
list: (filters?: Record<string, any>) =>
|
||||||
details: () => [...queryKeys.transactions.all(), 'detail'] as const,
|
[...queryKeys.transactions.lists(), filters] as const,
|
||||||
|
details: () => [...queryKeys.transactions.all(), "detail"] as const,
|
||||||
detail: (id: string) => [...queryKeys.transactions.details(), id] as const,
|
detail: (id: string) => [...queryKeys.transactions.details(), id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 예산 관련
|
// 예산 관련
|
||||||
budget: {
|
budget: {
|
||||||
all: () => ['budget'] as const,
|
all: () => ["budget"] as const,
|
||||||
data: () => [...queryKeys.budget.all(), 'data'] as const,
|
data: () => [...queryKeys.budget.all(), "data"] as const,
|
||||||
categories: () => [...queryKeys.budget.all(), 'categories'] as const,
|
categories: () => [...queryKeys.budget.all(), "categories"] as const,
|
||||||
stats: () => [...queryKeys.budget.all(), 'stats'] as const,
|
stats: () => [...queryKeys.budget.all(), "stats"] as const,
|
||||||
paymentMethods: () => [...queryKeys.budget.all(), 'paymentMethods'] as const,
|
paymentMethods: () =>
|
||||||
|
[...queryKeys.budget.all(), "paymentMethods"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 동기화 관련
|
// 동기화 관련
|
||||||
sync: {
|
sync: {
|
||||||
all: () => ['sync'] as const,
|
all: () => ["sync"] as const,
|
||||||
status: () => [...queryKeys.sync.all(), 'status'] as const,
|
status: () => [...queryKeys.sync.all(), "status"] as const,
|
||||||
lastSync: () => [...queryKeys.sync.all(), 'lastSync'] as const,
|
lastSync: () => [...queryKeys.sync.all(), "lastSync"] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -139,10 +141,10 @@ export const queryConfigs = {
|
|||||||
* 에러 핸들링 유틸리티
|
* 에러 핸들링 유틸리티
|
||||||
*/
|
*/
|
||||||
export const handleQueryError = (error: any, context?: string) => {
|
export const handleQueryError = (error: any, context?: string) => {
|
||||||
const errorMessage = error?.message || '알 수 없는 오류가 발생했습니다.';
|
const errorMessage = error?.message || "알 수 없는 오류가 발생했습니다.";
|
||||||
const errorCode = error?.code || 'UNKNOWN_ERROR';
|
const errorCode = error?.code || "UNKNOWN_ERROR";
|
||||||
|
|
||||||
syncLogger.error(`Query 에러 ${context ? `(${context})` : ''}:`, {
|
syncLogger.error(`Query 에러 ${context ? `(${context})` : ""}:`, {
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
code: errorCode,
|
code: errorCode,
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
@@ -150,16 +152,16 @@ export const handleQueryError = (error: any, context?: string) => {
|
|||||||
|
|
||||||
// 사용자에게 표시할 친화적인 에러 메시지 반환
|
// 사용자에게 표시할 친화적인 에러 메시지 반환
|
||||||
switch (errorCode) {
|
switch (errorCode) {
|
||||||
case 'NETWORK_ERROR':
|
case "NETWORK_ERROR":
|
||||||
return '네트워크 연결을 확인해주세요.';
|
return "네트워크 연결을 확인해주세요.";
|
||||||
case 'AUTH_ERROR':
|
case "AUTH_ERROR":
|
||||||
return '인증이 필요합니다. 다시 로그인해주세요.';
|
return "인증이 필요합니다. 다시 로그인해주세요.";
|
||||||
case 'FORBIDDEN':
|
case "FORBIDDEN":
|
||||||
return '접근 권한이 없습니다.';
|
return "접근 권한이 없습니다.";
|
||||||
case 'NOT_FOUND':
|
case "NOT_FOUND":
|
||||||
return '요청한 데이터를 찾을 수 없습니다.';
|
return "요청한 데이터를 찾을 수 없습니다.";
|
||||||
case 'SERVER_ERROR':
|
case "SERVER_ERROR":
|
||||||
return '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
return "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
|
||||||
default:
|
default:
|
||||||
return errorMessage;
|
return errorMessage;
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,9 @@ export const invalidateQueries = {
|
|||||||
|
|
||||||
// 특정 거래 무효화
|
// 특정 거래 무효화
|
||||||
transaction: (id: string) => {
|
transaction: (id: string) => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.transactions.detail(id) });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.transactions.detail(id),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 모든 예산 관련 쿼리 무효화
|
// 모든 예산 관련 쿼리 무효화
|
||||||
@@ -221,9 +225,9 @@ export const prefetchQueries = {
|
|||||||
*/
|
*/
|
||||||
export const isDevMode = import.meta.env.DEV;
|
export const isDevMode = import.meta.env.DEV;
|
||||||
|
|
||||||
syncLogger.info('TanStack Query 설정 완료', {
|
syncLogger.info("TanStack Query 설정 완료", {
|
||||||
staleTime: '5분',
|
staleTime: "5분",
|
||||||
gcTime: '30분',
|
gcTime: "30분",
|
||||||
retryEnabled: true,
|
retryEnabled: true,
|
||||||
devMode: isDevMode,
|
devMode: isDevMode,
|
||||||
});
|
});
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
* 모든 테스트에서 공통으로 사용되는 설정과 모킹을 정의합니다.
|
* 모든 테스트에서 공통으로 사용되는 설정과 모킹을 정의합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import '@testing-library/jest-dom';
|
import "@testing-library/jest-dom";
|
||||||
import { vi } from 'vitest';
|
import { vi } from "vitest";
|
||||||
|
|
||||||
// React Query 테스트 유틸리티
|
// React Query 테스트 유틸리티
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// 전역 모킹 설정
|
// 전역 모킹 설정
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
@@ -25,7 +25,7 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// matchMedia 모킹 (Radix UI 호환성)
|
// matchMedia 모킹 (Radix UI 호환성)
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query) => ({
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
@@ -48,17 +48,17 @@ const localStorageMock = {
|
|||||||
length: 0,
|
length: 0,
|
||||||
key: vi.fn(),
|
key: vi.fn(),
|
||||||
};
|
};
|
||||||
Object.defineProperty(window, 'localStorage', {
|
Object.defineProperty(window, "localStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
// sessionStorage 모킹
|
// sessionStorage 모킹
|
||||||
Object.defineProperty(window, 'sessionStorage', {
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 네비게이션 API 모킹
|
// 네비게이션 API 모킹
|
||||||
Object.defineProperty(navigator, 'onLine', {
|
Object.defineProperty(navigator, "onLine", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: true,
|
value: true,
|
||||||
});
|
});
|
||||||
@@ -67,12 +67,12 @@ Object.defineProperty(navigator, 'onLine', {
|
|||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: "OK",
|
||||||
json: vi.fn().mockResolvedValue({}),
|
json: vi.fn().mockResolvedValue({}),
|
||||||
text: vi.fn().mockResolvedValue(''),
|
text: vi.fn().mockResolvedValue(""),
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
url: '',
|
url: "",
|
||||||
type: 'basic',
|
type: "basic",
|
||||||
redirected: false,
|
redirected: false,
|
||||||
bodyUsed: false,
|
bodyUsed: false,
|
||||||
body: null,
|
body: null,
|
||||||
@@ -80,25 +80,25 @@ global.fetch = vi.fn().mockResolvedValue({
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Appwrite SDK 모킹
|
// Appwrite SDK 모킹
|
||||||
vi.mock('appwrite', () => ({
|
vi.mock("appwrite", () => ({
|
||||||
Client: vi.fn().mockImplementation(() => ({
|
Client: vi.fn().mockImplementation(() => ({
|
||||||
setEndpoint: vi.fn().mockReturnThis(),
|
setEndpoint: vi.fn().mockReturnThis(),
|
||||||
setProject: vi.fn().mockReturnThis(),
|
setProject: vi.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
Account: vi.fn().mockImplementation(() => ({
|
Account: vi.fn().mockImplementation(() => ({
|
||||||
get: vi.fn().mockResolvedValue({
|
get: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-user-id',
|
$id: "test-user-id",
|
||||||
email: 'test@example.com',
|
email: "test@example.com",
|
||||||
name: 'Test User',
|
name: "Test User",
|
||||||
}),
|
}),
|
||||||
createEmailPasswordSession: vi.fn().mockResolvedValue({
|
createEmailPasswordSession: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-session-id',
|
$id: "test-session-id",
|
||||||
userId: 'test-user-id',
|
userId: "test-user-id",
|
||||||
}),
|
}),
|
||||||
deleteSession: vi.fn().mockResolvedValue({}),
|
deleteSession: vi.fn().mockResolvedValue({}),
|
||||||
createAccount: vi.fn().mockResolvedValue({
|
createAccount: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-user-id',
|
$id: "test-user-id",
|
||||||
email: 'test@example.com',
|
email: "test@example.com",
|
||||||
}),
|
}),
|
||||||
createRecovery: vi.fn().mockResolvedValue({}),
|
createRecovery: vi.fn().mockResolvedValue({}),
|
||||||
})),
|
})),
|
||||||
@@ -108,30 +108,30 @@ vi.mock('appwrite', () => ({
|
|||||||
total: 0,
|
total: 0,
|
||||||
}),
|
}),
|
||||||
createDocument: vi.fn().mockResolvedValue({
|
createDocument: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-document-id',
|
$id: "test-document-id",
|
||||||
$createdAt: new Date().toISOString(),
|
$createdAt: new Date().toISOString(),
|
||||||
$updatedAt: new Date().toISOString(),
|
$updatedAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
updateDocument: vi.fn().mockResolvedValue({
|
updateDocument: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-document-id',
|
$id: "test-document-id",
|
||||||
$updatedAt: new Date().toISOString(),
|
$updatedAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
deleteDocument: vi.fn().mockResolvedValue({}),
|
deleteDocument: vi.fn().mockResolvedValue({}),
|
||||||
getDatabase: vi.fn().mockResolvedValue({
|
getDatabase: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-database-id',
|
$id: "test-database-id",
|
||||||
name: 'Test Database',
|
name: "Test Database",
|
||||||
}),
|
}),
|
||||||
createDatabase: vi.fn().mockResolvedValue({
|
createDatabase: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-database-id',
|
$id: "test-database-id",
|
||||||
name: 'Test Database',
|
name: "Test Database",
|
||||||
}),
|
}),
|
||||||
getCollection: vi.fn().mockResolvedValue({
|
getCollection: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-collection-id',
|
$id: "test-collection-id",
|
||||||
name: 'Test Collection',
|
name: "Test Collection",
|
||||||
}),
|
}),
|
||||||
createCollection: vi.fn().mockResolvedValue({
|
createCollection: vi.fn().mockResolvedValue({
|
||||||
$id: 'test-collection-id',
|
$id: "test-collection-id",
|
||||||
name: 'Test Collection',
|
name: "Test Collection",
|
||||||
}),
|
}),
|
||||||
createStringAttribute: vi.fn().mockResolvedValue({}),
|
createStringAttribute: vi.fn().mockResolvedValue({}),
|
||||||
createFloatAttribute: vi.fn().mockResolvedValue({}),
|
createFloatAttribute: vi.fn().mockResolvedValue({}),
|
||||||
@@ -157,29 +157,29 @@ vi.mock('appwrite', () => ({
|
|||||||
},
|
},
|
||||||
Role: {
|
Role: {
|
||||||
user: vi.fn((userId) => `user:${userId}`),
|
user: vi.fn((userId) => `user:${userId}`),
|
||||||
any: vi.fn(() => 'any'),
|
any: vi.fn(() => "any"),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// React Router 모킹
|
// React Router 모킹
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock("react-router-dom", async () => {
|
||||||
const actual = await vi.importActual('react-router-dom');
|
const actual = await vi.importActual("react-router-dom");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useNavigate: () => vi.fn(),
|
useNavigate: () => vi.fn(),
|
||||||
useLocation: () => ({
|
useLocation: () => ({
|
||||||
pathname: '/',
|
pathname: "/",
|
||||||
search: '',
|
search: "",
|
||||||
hash: '',
|
hash: "",
|
||||||
state: null,
|
state: null,
|
||||||
key: 'test',
|
key: "test",
|
||||||
}),
|
}),
|
||||||
useParams: () => ({}),
|
useParams: () => ({}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logger 모킹 (콘솔 출력 방지)
|
// Logger 모킹 (콘솔 출력 방지)
|
||||||
vi.mock('@/utils/logger', () => ({
|
vi.mock("@/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
@@ -207,7 +207,7 @@ vi.mock('@/utils/logger', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Toast 알림 모킹
|
// Toast 알림 모킹
|
||||||
vi.mock('@/hooks/useToast.wrapper', () => ({
|
vi.mock("@/hooks/useToast.wrapper", () => ({
|
||||||
toast: vi.fn(),
|
toast: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ export const createTestQueryClient = () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Date 객체 모킹 (일관된 테스트를 위해)
|
// Date 객체 모킹 (일관된 테스트를 위해)
|
||||||
const mockDate = new Date('2024-01-01T12:00:00.000Z');
|
const mockDate = new Date("2024-01-01T12:00:00.000Z");
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -249,8 +249,8 @@ const originalConsoleError = console.error;
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
console.error = (...args) => {
|
console.error = (...args) => {
|
||||||
if (
|
if (
|
||||||
typeof args[0] === 'string' &&
|
typeof args[0] === "string" &&
|
||||||
args[0].includes('Warning: ReactDOM.render is no longer supported')
|
args[0].includes("Warning: ReactDOM.render is no longer supported")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface AppState {
|
|||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
setGlobalLoading: (loading: boolean) => void;
|
setGlobalLoading: (loading: boolean) => void;
|
||||||
setGlobalError: (error: string | null) => void;
|
setGlobalError: (error: string | null) => void;
|
||||||
addNotification: (notification: Omit<Notification, 'id'>) => void;
|
addNotification: (notification: Omit<Notification, "id">) => void;
|
||||||
removeNotification: (id: string) => void;
|
removeNotification: (id: string) => void;
|
||||||
clearNotifications: () => void;
|
clearNotifications: () => void;
|
||||||
setLastSyncTime: (time: string) => void;
|
setLastSyncTime: (time: string) => void;
|
||||||
@@ -83,7 +83,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 알림 추가
|
// 알림 추가
|
||||||
addNotification: (notificationData: Omit<Notification, 'id'>) => {
|
addNotification: (notificationData: Omit<Notification, "id">) => {
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
...notificationData,
|
...notificationData,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -173,19 +173,20 @@ export const useNotifications = () => {
|
|||||||
notifications,
|
notifications,
|
||||||
addNotification,
|
addNotification,
|
||||||
removeNotification,
|
removeNotification,
|
||||||
clearNotifications
|
clearNotifications,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications,
|
notifications,
|
||||||
addNotification,
|
addNotification,
|
||||||
removeNotification,
|
removeNotification,
|
||||||
clearNotifications
|
clearNotifications,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSyncStatus = () => {
|
export const useSyncStatus = () => {
|
||||||
const { lastSyncTime, setLastSyncTime, isOnline, setOnlineStatus } = useAppStore();
|
const { lastSyncTime, setLastSyncTime, isOnline, setOnlineStatus } =
|
||||||
|
useAppStore();
|
||||||
return { lastSyncTime, setLastSyncTime, isOnline, setOnlineStatus };
|
return { lastSyncTime, setLastSyncTime, isOnline, setOnlineStatus };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
startSessionValidation,
|
startSessionValidation,
|
||||||
stopSessionValidation,
|
stopSessionValidation,
|
||||||
setupOnlineStatusListener,
|
setupOnlineStatusListener,
|
||||||
cleanupOnlineStatusListener
|
cleanupOnlineStatusListener,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import { authLogger } from "@/utils/logger";
|
import { authLogger } from "@/utils/logger";
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +1,167 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { getCategoryColor } from '../categoryColorUtils';
|
import { getCategoryColor } from "../categoryColorUtils";
|
||||||
|
|
||||||
describe('categoryColorUtils', () => {
|
describe("categoryColorUtils", () => {
|
||||||
describe('getCategoryColor', () => {
|
describe("getCategoryColor", () => {
|
||||||
describe('food category colors', () => {
|
describe("food category colors", () => {
|
||||||
it('returns correct color for food-related categories', () => {
|
it("returns correct color for food-related categories", () => {
|
||||||
expect(getCategoryColor('음식')).toBe('#81c784');
|
expect(getCategoryColor("음식")).toBe("#81c784");
|
||||||
expect(getCategoryColor('식비')).toBe('#81c784');
|
expect(getCategoryColor("식비")).toBe("#81c784");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles case insensitive food categories', () => {
|
it("handles case insensitive food categories", () => {
|
||||||
expect(getCategoryColor('음식')).toBe('#81c784');
|
expect(getCategoryColor("음식")).toBe("#81c784");
|
||||||
expect(getCategoryColor('음식')).toBe('#81c784');
|
expect(getCategoryColor("음식")).toBe("#81c784");
|
||||||
expect(getCategoryColor('식비')).toBe('#81c784');
|
expect(getCategoryColor("식비")).toBe("#81c784");
|
||||||
expect(getCategoryColor('식비')).toBe('#81c784');
|
expect(getCategoryColor("식비")).toBe("#81c784");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles food categories with extra text', () => {
|
it("handles food categories with extra text", () => {
|
||||||
expect(getCategoryColor('외식 음식')).toBe('#81c784');
|
expect(getCategoryColor("외식 음식")).toBe("#81c784");
|
||||||
expect(getCategoryColor('일반 식비')).toBe('#81c784');
|
expect(getCategoryColor("일반 식비")).toBe("#81c784");
|
||||||
expect(getCategoryColor('회사 식비 지원')).toBe('#81c784');
|
expect(getCategoryColor("회사 식비 지원")).toBe("#81c784");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shopping category colors', () => {
|
describe("shopping category colors", () => {
|
||||||
it('returns correct color for shopping-related categories', () => {
|
it("returns correct color for shopping-related categories", () => {
|
||||||
expect(getCategoryColor('쇼핑')).toBe('#AED581');
|
expect(getCategoryColor("쇼핑")).toBe("#AED581");
|
||||||
expect(getCategoryColor('생활비')).toBe('#AED581');
|
expect(getCategoryColor("생활비")).toBe("#AED581");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles case insensitive shopping categories', () => {
|
it("handles case insensitive shopping categories", () => {
|
||||||
expect(getCategoryColor('쇼핑')).toBe('#AED581');
|
expect(getCategoryColor("쇼핑")).toBe("#AED581");
|
||||||
expect(getCategoryColor('쇼핑')).toBe('#AED581');
|
expect(getCategoryColor("쇼핑")).toBe("#AED581");
|
||||||
expect(getCategoryColor('생활비')).toBe('#AED581');
|
expect(getCategoryColor("생활비")).toBe("#AED581");
|
||||||
expect(getCategoryColor('생활비')).toBe('#AED581');
|
expect(getCategoryColor("생활비")).toBe("#AED581");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles shopping categories with extra text', () => {
|
it("handles shopping categories with extra text", () => {
|
||||||
expect(getCategoryColor('온라인 쇼핑')).toBe('#AED581');
|
expect(getCategoryColor("온라인 쇼핑")).toBe("#AED581");
|
||||||
expect(getCategoryColor('월 생활비')).toBe('#AED581');
|
expect(getCategoryColor("월 생활비")).toBe("#AED581");
|
||||||
expect(getCategoryColor('필수 생활비 지출')).toBe('#AED581');
|
expect(getCategoryColor("필수 생활비 지출")).toBe("#AED581");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('transportation category colors', () => {
|
describe("transportation category colors", () => {
|
||||||
it('returns correct color for transportation categories', () => {
|
it("returns correct color for transportation categories", () => {
|
||||||
expect(getCategoryColor('교통')).toBe('#2E7D32');
|
expect(getCategoryColor("교통")).toBe("#2E7D32");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles case insensitive transportation categories', () => {
|
it("handles case insensitive transportation categories", () => {
|
||||||
expect(getCategoryColor('교통')).toBe('#2E7D32');
|
expect(getCategoryColor("교통")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('교통')).toBe('#2E7D32');
|
expect(getCategoryColor("교통")).toBe("#2E7D32");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles transportation categories with extra text', () => {
|
it("handles transportation categories with extra text", () => {
|
||||||
expect(getCategoryColor('대중교통')).toBe('#2E7D32');
|
expect(getCategoryColor("대중교통")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('교통비')).toBe('#2E7D32');
|
expect(getCategoryColor("교통비")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('버스 교통 요금')).toBe('#2E7D32');
|
expect(getCategoryColor("버스 교통 요금")).toBe("#2E7D32");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('other category colors', () => {
|
describe("other category colors", () => {
|
||||||
it('returns correct color for other categories', () => {
|
it("returns correct color for other categories", () => {
|
||||||
expect(getCategoryColor('기타')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles case insensitive other categories', () => {
|
it("handles case insensitive other categories", () => {
|
||||||
expect(getCategoryColor('기타')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타")).toBe("#9E9E9E");
|
||||||
expect(getCategoryColor('기타')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles other categories with extra text', () => {
|
it("handles other categories with extra text", () => {
|
||||||
expect(getCategoryColor('기타 지출')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타 지출")).toBe("#9E9E9E");
|
||||||
expect(getCategoryColor('기타 비용')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타 비용")).toBe("#9E9E9E");
|
||||||
expect(getCategoryColor('여러 기타 항목')).toBe('#9E9E9E');
|
expect(getCategoryColor("여러 기타 항목")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('default category color', () => {
|
describe("default category color", () => {
|
||||||
it('returns default color for unrecognized categories', () => {
|
it("returns default color for unrecognized categories", () => {
|
||||||
expect(getCategoryColor('의료')).toBe('#4CAF50');
|
expect(getCategoryColor("의료")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('취미')).toBe('#4CAF50');
|
expect(getCategoryColor("취미")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('교육')).toBe('#4CAF50');
|
expect(getCategoryColor("교육")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('여행')).toBe('#4CAF50');
|
expect(getCategoryColor("여행")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('운동')).toBe('#4CAF50');
|
expect(getCategoryColor("운동")).toBe("#4CAF50");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default color for empty or random strings', () => {
|
it("returns default color for empty or random strings", () => {
|
||||||
expect(getCategoryColor('')).toBe('#4CAF50');
|
expect(getCategoryColor("")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('random123')).toBe('#4CAF50');
|
expect(getCategoryColor("random123")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('xyz')).toBe('#4CAF50');
|
expect(getCategoryColor("xyz")).toBe("#4CAF50");
|
||||||
expect(getCategoryColor('unknown category')).toBe('#4CAF50');
|
expect(getCategoryColor("unknown category")).toBe("#4CAF50");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases and input handling', () => {
|
describe("edge cases and input handling", () => {
|
||||||
it('handles whitespace correctly', () => {
|
it("handles whitespace correctly", () => {
|
||||||
expect(getCategoryColor(' 음식 ')).toBe('#81c784');
|
expect(getCategoryColor(" 음식 ")).toBe("#81c784");
|
||||||
expect(getCategoryColor('\t쇼핑\n')).toBe('#AED581');
|
expect(getCategoryColor("\t쇼핑\n")).toBe("#AED581");
|
||||||
expect(getCategoryColor(' 교통 ')).toBe('#2E7D32');
|
expect(getCategoryColor(" 교통 ")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor(' 기타 ')).toBe('#9E9E9E');
|
expect(getCategoryColor(" 기타 ")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles mixed case with whitespace', () => {
|
it("handles mixed case with whitespace", () => {
|
||||||
expect(getCategoryColor(' 음식 ')).toBe('#81c784');
|
expect(getCategoryColor(" 음식 ")).toBe("#81c784");
|
||||||
expect(getCategoryColor(' ShOpPiNg ')).toBe('#4CAF50'); // English, so default
|
expect(getCategoryColor(" ShOpPiNg ")).toBe("#4CAF50"); // English, so default
|
||||||
expect(getCategoryColor(' 교통 ')).toBe('#2E7D32');
|
expect(getCategoryColor(" 교통 ")).toBe("#2E7D32");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles special characters', () => {
|
it("handles special characters", () => {
|
||||||
expect(getCategoryColor('음식!')).toBe('#81c784');
|
expect(getCategoryColor("음식!")).toBe("#81c784");
|
||||||
expect(getCategoryColor('쇼핑@')).toBe('#AED581');
|
expect(getCategoryColor("쇼핑@")).toBe("#AED581");
|
||||||
expect(getCategoryColor('교통#')).toBe('#2E7D32');
|
expect(getCategoryColor("교통#")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('기타$')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타$")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles numbers in category names', () => {
|
it("handles numbers in category names", () => {
|
||||||
expect(getCategoryColor('음식123')).toBe('#81c784');
|
expect(getCategoryColor("음식123")).toBe("#81c784");
|
||||||
expect(getCategoryColor('쇼핑456')).toBe('#AED581');
|
expect(getCategoryColor("쇼핑456")).toBe("#AED581");
|
||||||
expect(getCategoryColor('교통789')).toBe('#2E7D32');
|
expect(getCategoryColor("교통789")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('기타000')).toBe('#9E9E9E');
|
expect(getCategoryColor("기타000")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles very long category names', () => {
|
it("handles very long category names", () => {
|
||||||
const longCategory = '매우 긴 카테고리 이름인데 음식이라는 단어가 포함되어 있습니다';
|
const longCategory =
|
||||||
expect(getCategoryColor(longCategory)).toBe('#81c784');
|
"매우 긴 카테고리 이름인데 음식이라는 단어가 포함되어 있습니다";
|
||||||
|
expect(getCategoryColor(longCategory)).toBe("#81c784");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles mixed languages', () => {
|
it("handles mixed languages", () => {
|
||||||
expect(getCategoryColor('food 음식')).toBe('#81c784');
|
expect(getCategoryColor("food 음식")).toBe("#81c784");
|
||||||
expect(getCategoryColor('shopping 쇼핑')).toBe('#AED581');
|
expect(getCategoryColor("shopping 쇼핑")).toBe("#AED581");
|
||||||
expect(getCategoryColor('transport 교통')).toBe('#2E7D32');
|
expect(getCategoryColor("transport 교통")).toBe("#2E7D32");
|
||||||
expect(getCategoryColor('other 기타')).toBe('#9E9E9E');
|
expect(getCategoryColor("other 기타")).toBe("#9E9E9E");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('multiple keyword matches', () => {
|
describe("multiple keyword matches", () => {
|
||||||
it('prioritizes first match when multiple keywords present', () => {
|
it("prioritizes first match when multiple keywords present", () => {
|
||||||
// When multiple categories are mentioned, it should match the first one found
|
// When multiple categories are mentioned, it should match the first one found
|
||||||
expect(getCategoryColor('음식과 쇼핑')).toBe('#81c784'); // 음식 comes first in the if-else chain
|
expect(getCategoryColor("음식과 쇼핑")).toBe("#81c784"); // 음식 comes first in the if-else chain
|
||||||
expect(getCategoryColor('쇼핑과 교통')).toBe('#AED581'); // 쇼핑 comes first
|
expect(getCategoryColor("쇼핑과 교통")).toBe("#AED581"); // 쇼핑 comes first
|
||||||
expect(getCategoryColor('교통과 기타')).toBe('#2E7D32'); // 교통 comes first
|
expect(getCategoryColor("교통과 기타")).toBe("#2E7D32"); // 교통 comes first
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('consistency tests', () => {
|
describe("consistency tests", () => {
|
||||||
it('returns consistent colors for same normalized input', () => {
|
it("returns consistent colors for same normalized input", () => {
|
||||||
const testCases = [
|
const testCases = ["음식", " 음식 ", "음식", "음식!@#", "abc음식xyz"];
|
||||||
'음식',
|
|
||||||
' 음식 ',
|
|
||||||
'음식',
|
|
||||||
'음식!@#',
|
|
||||||
'abc음식xyz'
|
|
||||||
];
|
|
||||||
|
|
||||||
const expectedColor = '#81c784';
|
const expectedColor = "#81c784";
|
||||||
testCases.forEach(testCase => {
|
testCases.forEach((testCase) => {
|
||||||
expect(getCategoryColor(testCase)).toBe(expectedColor);
|
expect(getCategoryColor(testCase)).toBe(expectedColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has unique colors for each main category', () => {
|
it("has unique colors for each main category", () => {
|
||||||
const colors = {
|
const colors = {
|
||||||
food: getCategoryColor('음식'),
|
food: getCategoryColor("음식"),
|
||||||
shopping: getCategoryColor('쇼핑'),
|
shopping: getCategoryColor("쇼핑"),
|
||||||
transport: getCategoryColor('교통'),
|
transport: getCategoryColor("교통"),
|
||||||
other: getCategoryColor('기타'),
|
other: getCategoryColor("기타"),
|
||||||
default: getCategoryColor('unknown')
|
default: getCategoryColor("unknown"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const uniqueColors = new Set(Object.values(colors));
|
const uniqueColors = new Set(Object.values(colors));
|
||||||
@@ -174,12 +169,12 @@ describe('categoryColorUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('color format validation', () => {
|
describe("color format validation", () => {
|
||||||
it('returns valid hex color format', () => {
|
it("returns valid hex color format", () => {
|
||||||
const categories = ['음식', '쇼핑', '교통', '기타', 'unknown'];
|
const categories = ["음식", "쇼핑", "교통", "기타", "unknown"];
|
||||||
const hexColorRegex = /^#[0-9A-F]{6}$/i;
|
const hexColorRegex = /^#[0-9A-F]{6}$/i;
|
||||||
|
|
||||||
categories.forEach(category => {
|
categories.forEach((category) => {
|
||||||
const color = getCategoryColor(category);
|
const color = getCategoryColor(category);
|
||||||
expect(color).toMatch(hexColorRegex);
|
expect(color).toMatch(hexColorRegex);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,106 +1,110 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatCurrency, extractNumber, formatInputCurrency } from '../currencyFormatter';
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
extractNumber,
|
||||||
|
formatInputCurrency,
|
||||||
|
} from "../currencyFormatter";
|
||||||
|
|
||||||
describe('currencyFormatter', () => {
|
describe("currencyFormatter", () => {
|
||||||
describe('formatCurrency', () => {
|
describe("formatCurrency", () => {
|
||||||
it('formats positive numbers correctly', () => {
|
it("formats positive numbers correctly", () => {
|
||||||
expect(formatCurrency(1000)).toBe('1,000원');
|
expect(formatCurrency(1000)).toBe("1,000원");
|
||||||
expect(formatCurrency(1234567)).toBe('1,234,567원');
|
expect(formatCurrency(1234567)).toBe("1,234,567원");
|
||||||
expect(formatCurrency(100)).toBe('100원');
|
expect(formatCurrency(100)).toBe("100원");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats zero correctly', () => {
|
it("formats zero correctly", () => {
|
||||||
expect(formatCurrency(0)).toBe('0원');
|
expect(formatCurrency(0)).toBe("0원");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats negative numbers correctly', () => {
|
it("formats negative numbers correctly", () => {
|
||||||
expect(formatCurrency(-1000)).toBe('-1,000원');
|
expect(formatCurrency(-1000)).toBe("-1,000원");
|
||||||
expect(formatCurrency(-123456)).toBe('-123,456원');
|
expect(formatCurrency(-123456)).toBe("-123,456원");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles decimal numbers as-is (toLocaleString preserves decimals)', () => {
|
it("handles decimal numbers as-is (toLocaleString preserves decimals)", () => {
|
||||||
expect(formatCurrency(1000.99)).toBe('1,000.99원');
|
expect(formatCurrency(1000.99)).toBe("1,000.99원");
|
||||||
expect(formatCurrency(999.1)).toBe('999.1원');
|
expect(formatCurrency(999.1)).toBe("999.1원");
|
||||||
expect(formatCurrency(1000.0)).toBe('1,000원');
|
expect(formatCurrency(1000.0)).toBe("1,000원");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles very large numbers', () => {
|
it("handles very large numbers", () => {
|
||||||
expect(formatCurrency(1000000000)).toBe('1,000,000,000원');
|
expect(formatCurrency(1000000000)).toBe("1,000,000,000원");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractNumber', () => {
|
describe("extractNumber", () => {
|
||||||
it('extracts numbers from currency strings', () => {
|
it("extracts numbers from currency strings", () => {
|
||||||
expect(extractNumber('1,000원')).toBe(1000);
|
expect(extractNumber("1,000원")).toBe(1000);
|
||||||
expect(extractNumber('1,234,567원')).toBe(1234567);
|
expect(extractNumber("1,234,567원")).toBe(1234567);
|
||||||
expect(extractNumber('100원')).toBe(100);
|
expect(extractNumber("100원")).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts numbers from strings with mixed characters', () => {
|
it("extracts numbers from strings with mixed characters", () => {
|
||||||
expect(extractNumber('abc123def456')).toBe(123456);
|
expect(extractNumber("abc123def456")).toBe(123456);
|
||||||
expect(extractNumber('$1,000!')).toBe(1000);
|
expect(extractNumber("$1,000!")).toBe(1000);
|
||||||
expect(extractNumber('test 500 won')).toBe(500);
|
expect(extractNumber("test 500 won")).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 for strings without numbers', () => {
|
it("returns 0 for strings without numbers", () => {
|
||||||
expect(extractNumber('')).toBe(0);
|
expect(extractNumber("")).toBe(0);
|
||||||
expect(extractNumber('abc')).toBe(0);
|
expect(extractNumber("abc")).toBe(0);
|
||||||
expect(extractNumber('원')).toBe(0);
|
expect(extractNumber("원")).toBe(0);
|
||||||
expect(extractNumber('!@#$%')).toBe(0);
|
expect(extractNumber("!@#$%")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles strings with only numbers', () => {
|
it("handles strings with only numbers", () => {
|
||||||
expect(extractNumber('1000')).toBe(1000);
|
expect(extractNumber("1000")).toBe(1000);
|
||||||
expect(extractNumber('0')).toBe(0);
|
expect(extractNumber("0")).toBe(0);
|
||||||
expect(extractNumber('123456789')).toBe(123456789);
|
expect(extractNumber("123456789")).toBe(123456789);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-digit characters', () => {
|
it("ignores non-digit characters", () => {
|
||||||
expect(extractNumber('1,000.50원')).toBe(100050);
|
expect(extractNumber("1,000.50원")).toBe(100050);
|
||||||
expect(extractNumber('-1000')).toBe(1000); // 음수 기호는 제거됨
|
expect(extractNumber("-1000")).toBe(1000); // 음수 기호는 제거됨
|
||||||
expect(extractNumber('+1000')).toBe(1000); // 양수 기호는 제거됨
|
expect(extractNumber("+1000")).toBe(1000); // 양수 기호는 제거됨
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatInputCurrency', () => {
|
describe("formatInputCurrency", () => {
|
||||||
it('formats numeric strings with commas', () => {
|
it("formats numeric strings with commas", () => {
|
||||||
expect(formatInputCurrency('1000')).toBe('1,000');
|
expect(formatInputCurrency("1000")).toBe("1,000");
|
||||||
expect(formatInputCurrency('1234567')).toBe('1,234,567');
|
expect(formatInputCurrency("1234567")).toBe("1,234,567");
|
||||||
expect(formatInputCurrency('100')).toBe('100');
|
expect(formatInputCurrency("100")).toBe("100");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles strings with existing commas', () => {
|
it("handles strings with existing commas", () => {
|
||||||
expect(formatInputCurrency('1,000')).toBe('1,000');
|
expect(formatInputCurrency("1,000")).toBe("1,000");
|
||||||
expect(formatInputCurrency('1,234,567')).toBe('1,234,567');
|
expect(formatInputCurrency("1,234,567")).toBe("1,234,567");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts and formats numbers from mixed strings', () => {
|
it("extracts and formats numbers from mixed strings", () => {
|
||||||
expect(formatInputCurrency('1000원')).toBe('1,000');
|
expect(formatInputCurrency("1000원")).toBe("1,000");
|
||||||
expect(formatInputCurrency('abc1000def')).toBe('1,000');
|
expect(formatInputCurrency("abc1000def")).toBe("1,000");
|
||||||
expect(formatInputCurrency('$1000!')).toBe('1,000');
|
expect(formatInputCurrency("$1000!")).toBe("1,000");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty string for non-numeric input', () => {
|
it("returns empty string for non-numeric input", () => {
|
||||||
expect(formatInputCurrency('')).toBe('');
|
expect(formatInputCurrency("")).toBe("");
|
||||||
expect(formatInputCurrency('abc')).toBe('');
|
expect(formatInputCurrency("abc")).toBe("");
|
||||||
expect(formatInputCurrency('!@#$%')).toBe('');
|
expect(formatInputCurrency("!@#$%")).toBe("");
|
||||||
expect(formatInputCurrency('원')).toBe('');
|
expect(formatInputCurrency("원")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles zero correctly', () => {
|
it("handles zero correctly", () => {
|
||||||
expect(formatInputCurrency('0')).toBe('0');
|
expect(formatInputCurrency("0")).toBe("0");
|
||||||
expect(formatInputCurrency('00')).toBe('0');
|
expect(formatInputCurrency("00")).toBe("0");
|
||||||
expect(formatInputCurrency('000')).toBe('0');
|
expect(formatInputCurrency("000")).toBe("0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles large numbers', () => {
|
it("handles large numbers", () => {
|
||||||
expect(formatInputCurrency('1000000000')).toBe('1,000,000,000');
|
expect(formatInputCurrency("1000000000")).toBe("1,000,000,000");
|
||||||
expect(formatInputCurrency('999999999')).toBe('999,999,999');
|
expect(formatInputCurrency("999999999")).toBe("999,999,999");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes leading zeros', () => {
|
it("removes leading zeros", () => {
|
||||||
expect(formatInputCurrency('001000')).toBe('1,000');
|
expect(formatInputCurrency("001000")).toBe("1,000");
|
||||||
expect(formatInputCurrency('000100')).toBe('100');
|
expect(formatInputCurrency("000100")).toBe("100");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,170 +1,177 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
filterTransactionsByMonth,
|
filterTransactionsByMonth,
|
||||||
filterTransactionsByQuery,
|
filterTransactionsByQuery,
|
||||||
calculateTotalExpenses
|
calculateTotalExpenses,
|
||||||
} from '../transactionUtils';
|
} from "../transactionUtils";
|
||||||
import { Transaction } from '@/contexts/budget/types';
|
import { Transaction } from "@/contexts/budget/types";
|
||||||
|
|
||||||
// Mock transaction data for testing
|
// Mock transaction data for testing
|
||||||
const createMockTransaction = (overrides: Partial<Transaction> = {}): Transaction => ({
|
const createMockTransaction = (
|
||||||
id: 'test-id',
|
overrides: Partial<Transaction> = {}
|
||||||
title: 'Test Transaction',
|
): Transaction => ({
|
||||||
|
id: "test-id",
|
||||||
|
title: "Test Transaction",
|
||||||
amount: 1000,
|
amount: 1000,
|
||||||
date: '2024-06-15',
|
date: "2024-06-15",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
type: 'expense',
|
type: "expense",
|
||||||
paymentMethod: '신용카드',
|
paymentMethod: "신용카드",
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('transactionUtils', () => {
|
describe("transactionUtils", () => {
|
||||||
describe('filterTransactionsByMonth', () => {
|
describe("filterTransactionsByMonth", () => {
|
||||||
const mockTransactions: Transaction[] = [
|
const mockTransactions: Transaction[] = [
|
||||||
createMockTransaction({ id: '1', date: '2024-06-01', amount: 1000 }),
|
createMockTransaction({ id: "1", date: "2024-06-01", amount: 1000 }),
|
||||||
createMockTransaction({ id: '2', date: '2024-06-15', amount: 2000 }),
|
createMockTransaction({ id: "2", date: "2024-06-15", amount: 2000 }),
|
||||||
createMockTransaction({ id: '3', date: '2024-07-01', amount: 3000 }),
|
createMockTransaction({ id: "3", date: "2024-07-01", amount: 3000 }),
|
||||||
createMockTransaction({ id: '4', date: '2024-05-30', amount: 4000 }),
|
createMockTransaction({ id: "4", date: "2024-05-30", amount: 4000 }),
|
||||||
createMockTransaction({ id: '5', date: '2024-06-30', amount: 5000, type: 'income' }),
|
createMockTransaction({
|
||||||
|
id: "5",
|
||||||
|
date: "2024-06-30",
|
||||||
|
amount: 5000,
|
||||||
|
type: "income",
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
it('filters transactions by month correctly', () => {
|
it("filters transactions by month correctly", () => {
|
||||||
const result = filterTransactionsByMonth(mockTransactions, '2024-06');
|
const result = filterTransactionsByMonth(mockTransactions, "2024-06");
|
||||||
|
|
||||||
expect(result).toHaveLength(2); // Only expense transactions in June
|
expect(result).toHaveLength(2); // Only expense transactions in June
|
||||||
expect(result.map(t => t.id)).toEqual(['1', '2']);
|
expect(result.map((t) => t.id)).toEqual(["1", "2"]);
|
||||||
expect(result.every(t => t.type === 'expense')).toBe(true);
|
expect(result.every((t) => t.type === "expense")).toBe(true);
|
||||||
expect(result.every(t => t.date.includes('2024-06'))).toBe(true);
|
expect(result.every((t) => t.date.includes("2024-06"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array for non-matching month', () => {
|
it("returns empty array for non-matching month", () => {
|
||||||
const result = filterTransactionsByMonth(mockTransactions, '2024-12');
|
const result = filterTransactionsByMonth(mockTransactions, "2024-12");
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes income transactions', () => {
|
it("excludes income transactions", () => {
|
||||||
const result = filterTransactionsByMonth(mockTransactions, '2024-06');
|
const result = filterTransactionsByMonth(mockTransactions, "2024-06");
|
||||||
const incomeTransaction = result.find(t => t.type === 'income');
|
const incomeTransaction = result.find((t) => t.type === "income");
|
||||||
expect(incomeTransaction).toBeUndefined();
|
expect(incomeTransaction).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles partial month string matching', () => {
|
it("handles partial month string matching", () => {
|
||||||
const result = filterTransactionsByMonth(mockTransactions, '2024-0');
|
const result = filterTransactionsByMonth(mockTransactions, "2024-0");
|
||||||
expect(result).toHaveLength(4); // All expense transactions with '2024-0' in date (includes 2024-05, 2024-06, 2024-07)
|
expect(result).toHaveLength(4); // All expense transactions with '2024-0' in date (includes 2024-05, 2024-06, 2024-07)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty transaction array', () => {
|
it("handles empty transaction array", () => {
|
||||||
const result = filterTransactionsByMonth([], '2024-06');
|
const result = filterTransactionsByMonth([], "2024-06");
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty month string', () => {
|
it("handles empty month string", () => {
|
||||||
const result = filterTransactionsByMonth(mockTransactions, '');
|
const result = filterTransactionsByMonth(mockTransactions, "");
|
||||||
expect(result).toHaveLength(4); // All expense transactions (empty string matches all)
|
expect(result).toHaveLength(4); // All expense transactions (empty string matches all)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterTransactionsByQuery', () => {
|
describe("filterTransactionsByQuery", () => {
|
||||||
const mockTransactions: Transaction[] = [
|
const mockTransactions: Transaction[] = [
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '1',
|
id: "1",
|
||||||
title: 'Coffee Shop',
|
title: "Coffee Shop",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
amount: 5000
|
amount: 5000,
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '2',
|
id: "2",
|
||||||
title: 'Grocery Store',
|
title: "Grocery Store",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
amount: 30000
|
amount: 30000,
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '3',
|
id: "3",
|
||||||
title: 'Gas Station',
|
title: "Gas Station",
|
||||||
category: 'Transportation',
|
category: "Transportation",
|
||||||
amount: 50000
|
amount: 50000,
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '4',
|
id: "4",
|
||||||
title: 'Restaurant',
|
title: "Restaurant",
|
||||||
category: 'Dining',
|
category: "Dining",
|
||||||
amount: 25000
|
amount: 25000,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
it('filters by transaction title (case insensitive)', () => {
|
it("filters by transaction title (case insensitive)", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, 'coffee');
|
const result = filterTransactionsByQuery(mockTransactions, "coffee");
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Coffee Shop');
|
expect(result[0].title).toBe("Coffee Shop");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by category (case insensitive)', () => {
|
it("filters by category (case insensitive)", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, 'food');
|
const result = filterTransactionsByQuery(mockTransactions, "food");
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result.every(t => t.category === 'Food')).toBe(true);
|
expect(result.every((t) => t.category === "Food")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by partial matches', () => {
|
it("filters by partial matches", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, 'shop');
|
const result = filterTransactionsByQuery(mockTransactions, "shop");
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Coffee Shop');
|
expect(result[0].title).toBe("Coffee Shop");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all transactions for empty query', () => {
|
it("returns all transactions for empty query", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, '');
|
const result = filterTransactionsByQuery(mockTransactions, "");
|
||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
expect(result).toEqual(mockTransactions);
|
expect(result).toEqual(mockTransactions);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all transactions for whitespace-only query', () => {
|
it("returns all transactions for whitespace-only query", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, ' ');
|
const result = filterTransactionsByQuery(mockTransactions, " ");
|
||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
expect(result).toEqual(mockTransactions);
|
expect(result).toEqual(mockTransactions);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles no matches', () => {
|
it("handles no matches", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, 'nonexistent');
|
const result = filterTransactionsByQuery(mockTransactions, "nonexistent");
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles special characters in query', () => {
|
it("handles special characters in query", () => {
|
||||||
const specialTransactions = [
|
const specialTransactions = [
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '1',
|
id: "1",
|
||||||
title: 'Store & More',
|
title: "Store & More",
|
||||||
category: 'Shopping'
|
category: "Shopping",
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '2',
|
id: "2",
|
||||||
title: 'Regular Store',
|
title: "Regular Store",
|
||||||
category: 'Shopping'
|
category: "Shopping",
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = filterTransactionsByQuery(specialTransactions, '&');
|
const result = filterTransactionsByQuery(specialTransactions, "&");
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Store & More');
|
expect(result[0].title).toBe("Store & More");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims whitespace from query', () => {
|
it("trims whitespace from query", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, ' coffee ');
|
const result = filterTransactionsByQuery(mockTransactions, " coffee ");
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Coffee Shop');
|
expect(result[0].title).toBe("Coffee Shop");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches both title and category in single query', () => {
|
it("matches both title and category in single query", () => {
|
||||||
const result = filterTransactionsByQuery(mockTransactions, 'o'); // matches 'Coffee', 'Food', etc.
|
const result = filterTransactionsByQuery(mockTransactions, "o"); // matches 'Coffee', 'Food', etc.
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty transaction array', () => {
|
it("handles empty transaction array", () => {
|
||||||
const result = filterTransactionsByQuery([], 'test');
|
const result = filterTransactionsByQuery([], "test");
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateTotalExpenses', () => {
|
describe("calculateTotalExpenses", () => {
|
||||||
it('calculates total for multiple transactions', () => {
|
it("calculates total for multiple transactions", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 1000 }),
|
createMockTransaction({ amount: 1000 }),
|
||||||
createMockTransaction({ amount: 2000 }),
|
createMockTransaction({ amount: 2000 }),
|
||||||
@@ -175,12 +182,12 @@ describe('transactionUtils', () => {
|
|||||||
expect(result).toBe(6000);
|
expect(result).toBe(6000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 for empty array', () => {
|
it("returns 0 for empty array", () => {
|
||||||
const result = calculateTotalExpenses([]);
|
const result = calculateTotalExpenses([]);
|
||||||
expect(result).toBe(0);
|
expect(result).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles single transaction', () => {
|
it("handles single transaction", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 5000 }),
|
createMockTransaction({ amount: 5000 }),
|
||||||
];
|
];
|
||||||
@@ -189,7 +196,7 @@ describe('transactionUtils', () => {
|
|||||||
expect(result).toBe(5000);
|
expect(result).toBe(5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles zero amounts', () => {
|
it("handles zero amounts", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 0 }),
|
createMockTransaction({ amount: 0 }),
|
||||||
createMockTransaction({ amount: 1000 }),
|
createMockTransaction({ amount: 1000 }),
|
||||||
@@ -200,7 +207,7 @@ describe('transactionUtils', () => {
|
|||||||
expect(result).toBe(1000);
|
expect(result).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles negative amounts (refunds)', () => {
|
it("handles negative amounts (refunds)", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 1000 }),
|
createMockTransaction({ amount: 1000 }),
|
||||||
createMockTransaction({ amount: -500 }), // refund
|
createMockTransaction({ amount: -500 }), // refund
|
||||||
@@ -211,7 +218,7 @@ describe('transactionUtils', () => {
|
|||||||
expect(result).toBe(2500);
|
expect(result).toBe(2500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles large amounts', () => {
|
it("handles large amounts", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 999999 }),
|
createMockTransaction({ amount: 999999 }),
|
||||||
createMockTransaction({ amount: 1 }),
|
createMockTransaction({ amount: 1 }),
|
||||||
@@ -221,7 +228,7 @@ describe('transactionUtils', () => {
|
|||||||
expect(result).toBe(1000000);
|
expect(result).toBe(1000000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles decimal amounts (though typically avoided)', () => {
|
it("handles decimal amounts (though typically avoided)", () => {
|
||||||
const transactions: Transaction[] = [
|
const transactions: Transaction[] = [
|
||||||
createMockTransaction({ amount: 1000.5 }),
|
createMockTransaction({ amount: 1000.5 }),
|
||||||
createMockTransaction({ amount: 999.5 }),
|
createMockTransaction({ amount: 999.5 }),
|
||||||
@@ -232,46 +239,49 @@ describe('transactionUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('integration tests', () => {
|
describe("integration tests", () => {
|
||||||
const mockTransactions: Transaction[] = [
|
const mockTransactions: Transaction[] = [
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '1',
|
id: "1",
|
||||||
title: 'Coffee Shop',
|
title: "Coffee Shop",
|
||||||
amount: 5000,
|
amount: 5000,
|
||||||
date: '2024-06-01',
|
date: "2024-06-01",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
type: 'expense'
|
type: "expense",
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '2',
|
id: "2",
|
||||||
title: 'Grocery Store',
|
title: "Grocery Store",
|
||||||
amount: 30000,
|
amount: 30000,
|
||||||
date: '2024-06-15',
|
date: "2024-06-15",
|
||||||
category: 'Food',
|
category: "Food",
|
||||||
type: 'expense'
|
type: "expense",
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '3',
|
id: "3",
|
||||||
title: 'Gas Station',
|
title: "Gas Station",
|
||||||
amount: 50000,
|
amount: 50000,
|
||||||
date: '2024-07-01',
|
date: "2024-07-01",
|
||||||
category: 'Transportation',
|
category: "Transportation",
|
||||||
type: 'expense'
|
type: "expense",
|
||||||
}),
|
}),
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
id: '4',
|
id: "4",
|
||||||
title: 'Salary',
|
title: "Salary",
|
||||||
amount: 3000000,
|
amount: 3000000,
|
||||||
date: '2024-06-01',
|
date: "2024-06-01",
|
||||||
category: 'Income',
|
category: "Income",
|
||||||
type: 'income'
|
type: "income",
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
it('chains filtering operations correctly', () => {
|
it("chains filtering operations correctly", () => {
|
||||||
// Filter by month, then by query, then calculate total
|
// Filter by month, then by query, then calculate total
|
||||||
const monthFiltered = filterTransactionsByMonth(mockTransactions, '2024-06');
|
const monthFiltered = filterTransactionsByMonth(
|
||||||
const queryFiltered = filterTransactionsByQuery(monthFiltered, 'food');
|
mockTransactions,
|
||||||
|
"2024-06"
|
||||||
|
);
|
||||||
|
const queryFiltered = filterTransactionsByQuery(monthFiltered, "food");
|
||||||
const total = calculateTotalExpenses(queryFiltered);
|
const total = calculateTotalExpenses(queryFiltered);
|
||||||
|
|
||||||
expect(monthFiltered).toHaveLength(2); // Only June expenses
|
expect(monthFiltered).toHaveLength(2); // Only June expenses
|
||||||
@@ -279,9 +289,12 @@ describe('transactionUtils', () => {
|
|||||||
expect(total).toBe(35000); // 5000 + 30000
|
expect(total).toBe(35000); // 5000 + 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty results in chained operations', () => {
|
it("handles empty results in chained operations", () => {
|
||||||
const monthFiltered = filterTransactionsByMonth(mockTransactions, '2024-12');
|
const monthFiltered = filterTransactionsByMonth(
|
||||||
const queryFiltered = filterTransactionsByQuery(monthFiltered, 'any');
|
mockTransactions,
|
||||||
|
"2024-12"
|
||||||
|
);
|
||||||
|
const queryFiltered = filterTransactionsByQuery(monthFiltered, "any");
|
||||||
const total = calculateTotalExpenses(queryFiltered);
|
const total = calculateTotalExpenses(queryFiltered);
|
||||||
|
|
||||||
expect(monthFiltered).toHaveLength(0);
|
expect(monthFiltered).toHaveLength(0);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import path from 'path'
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -12,10 +12,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
// 브라우저 환경 시뮬레이션
|
// 브라우저 환경 시뮬레이션
|
||||||
environment: 'jsdom',
|
environment: "jsdom",
|
||||||
|
|
||||||
// 전역 설정 파일
|
// 전역 설정 파일
|
||||||
setupFiles: ['./src/setupTests.ts'],
|
setupFiles: ["./src/setupTests.ts"],
|
||||||
|
|
||||||
// 전역 변수 설정
|
// 전역 변수 설정
|
||||||
globals: true,
|
globals: true,
|
||||||
@@ -23,40 +23,40 @@ export default defineConfig({
|
|||||||
// CSS 모듈 및 스타일 파일 무시
|
// CSS 모듈 및 스타일 파일 무시
|
||||||
css: {
|
css: {
|
||||||
modules: {
|
modules: {
|
||||||
classNameStrategy: 'non-scoped',
|
classNameStrategy: "non-scoped",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 포함할 파일 패턴
|
// 포함할 파일 패턴
|
||||||
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||||
|
|
||||||
// 제외할 파일 패턴
|
// 제외할 파일 패턴
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules',
|
"node_modules",
|
||||||
'dist',
|
"dist",
|
||||||
'.git',
|
".git",
|
||||||
'.cache',
|
".cache",
|
||||||
'ccusage/**',
|
"ccusage/**",
|
||||||
'**/.{idea,git,cache,output,temp}/**',
|
"**/.{idea,git,cache,output,temp}/**",
|
||||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'
|
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
|
||||||
],
|
],
|
||||||
|
|
||||||
// 커버리지 설정
|
// 커버리지 설정
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: "v8",
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ["text", "json", "html"],
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules/',
|
"node_modules/",
|
||||||
'src/setupTests.ts',
|
"src/setupTests.ts",
|
||||||
'**/*.d.ts',
|
"**/*.d.ts",
|
||||||
'**/*.config.{js,ts}',
|
"**/*.config.{js,ts}",
|
||||||
'**/index.ts', // 단순 re-export 파일들
|
"**/index.ts", // 단순 re-export 파일들
|
||||||
'src/main.tsx', // 앱 진입점
|
"src/main.tsx", // 앱 진입점
|
||||||
'src/vite-env.d.ts',
|
"src/vite-env.d.ts",
|
||||||
'**/*.stories.{js,ts,jsx,tsx}', // Storybook 파일
|
"**/*.stories.{js,ts,jsx,tsx}", // Storybook 파일
|
||||||
'coverage/**',
|
"coverage/**",
|
||||||
'dist/**',
|
"dist/**",
|
||||||
'**/.{git,cache,output,temp}/**',
|
"**/.{git,cache,output,temp}/**",
|
||||||
],
|
],
|
||||||
// 80% 커버리지 목표
|
// 80% 커버리지 목표
|
||||||
thresholds: {
|
thresholds: {
|
||||||
@@ -71,15 +71,16 @@ export default defineConfig({
|
|||||||
|
|
||||||
// 테스트 실행 환경 변수
|
// 테스트 실행 환경 변수
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: "test",
|
||||||
VITE_APPWRITE_URL: 'https://test.appwrite.io/v1',
|
VITE_APPWRITE_URL: "https://test.appwrite.io/v1",
|
||||||
VITE_APPWRITE_PROJECT_ID: 'test-project-id',
|
VITE_APPWRITE_PROJECT_ID: "test-project-id",
|
||||||
VITE_APPWRITE_DATABASE_ID: 'test-database-id',
|
VITE_APPWRITE_DATABASE_ID: "test-database-id",
|
||||||
VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID: 'test-transactions-collection-id',
|
VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID:
|
||||||
|
"test-transactions-collection-id",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테스트 병렬 실행 및 성능 설정
|
// 테스트 병렬 실행 및 성능 설정
|
||||||
pool: 'threads',
|
pool: "threads",
|
||||||
poolOptions: {
|
poolOptions: {
|
||||||
threads: {
|
threads: {
|
||||||
singleThread: false,
|
singleThread: false,
|
||||||
@@ -88,11 +89,11 @@ export default defineConfig({
|
|||||||
|
|
||||||
// 테스트 파일 변경 감지
|
// 테스트 파일 변경 감지
|
||||||
watch: {
|
watch: {
|
||||||
ignore: ['**/node_modules/**', '**/dist/**'],
|
ignore: ["**/node_modules/**", "**/dist/**"],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 로그 레벨 설정
|
// 로그 레벨 설정
|
||||||
logLevel: 'info',
|
logLevel: "info",
|
||||||
|
|
||||||
// 재시도 설정
|
// 재시도 설정
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user