diff --git a/.env.development b/.env.development index cf528d62..5984ace4 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,3 @@ -NEXT_PUBLIC_API_HOST_NAME="https://pfplay-api.app/api/" -NEXT_PUBLIC_API_WS_HOST_NAME="wss://pfplay-api.app/ws" +NEXT_PUBLIC_API_HOST_NAME="http://localhost:8080/api/" +NEXT_PUBLIC_API_WS_HOST_NAME="ws://localhost:8080/ws" NEXT_PUBLIC_USE_MOCK="false" diff --git a/.swcrc b/.swcrc index d745ad23..d4f9d79b 100644 --- a/.swcrc +++ b/.swcrc @@ -12,13 +12,16 @@ "jsc": { "parser": { "syntax": "typescript", - "tsx": false, + "tsx": true, "decorators": true, "dynamicImport": true }, "transform": { "legacyDecorator": true, - "decoratorMetadata": true + "decoratorMetadata": true, + "react": { + "runtime": "automatic" + } } }, "module": { diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6ae50227..fd6219e0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -43,6 +43,27 @@ FSD(Frontend Structure Design) 아키텍쳐를 기반으로 작업합니다. - ext - `png|jpg|jpeg|gif|...` - `/public/images/` 디렉터리에 넣습니다. +## Testing + +> Last Update (26.03.02) + +```bash +# 전체 테스트 +yarn test + +# 커버리지 없이 빠르게 실행 +npx jest --no-coverage + +# 특정 경로만 실행 +npx jest src/shared/ui --no-coverage +``` + +- 테스트 파일은 소스 파일과 **동일 디렉토리에 co-locate** 합니다. +- 네이밍: `{name}.test.ts`, `{name}.component.test.tsx`, `{name}.hook.test.ts`, `{name}.integration.test.ts` +- MSW 통합 테스트 작성 시 `import '@/shared/api/__test__/msw-server'`를 반드시 추가해야 합니다. + +상세 가이드는 [TESTING.md](./TESTING.md)를 참고하세요. + ## react-query > Last Update (25.05.11) diff --git a/docs/DOCS_ENTRY.md b/docs/DOCS_ENTRY.md index 1236a51c..5c4f2b13 100644 --- a/docs/DOCS_ENTRY.md +++ b/docs/DOCS_ENTRY.md @@ -1,13 +1,20 @@ -# 250519 기준 프로젝트 문서 목록 +# 프로젝트 문서 목록 + +> Last Update (26.03.02) ## root ### docs -- [docs/REACT_QUERY.md](./REACT_QUERY.md) - 서버 상태 관리를 위한 React Query 라이브러리 사용 가이드. API 데이터 호출, 캐싱, 동기화 등을 효율적으로 처리하고 싶을 때 참고하세요. - [docs/README.md](./README.md) - 프로젝트의 전반적인 개요, 설정 방법, 실행 방법 등을 안내합니다. 프로젝트를 처음 시작하거나 전체 구조를 파악하고 싶을 때 읽어보세요. - [docs/CONTRIBUTING.md](./CONTRIBUTING.md) - 프로젝트에 기여하는 방법을 안내합니다. 코드 스타일, 브랜치 전략, PR 규칙 등을 확인할 수 있습니다. - [docs/FLOW.md](./FLOW.md) - 프로젝트의 주요 기능 흐름 또는 개발 워크플로우를 설명합니다. 특정 기능의 동작 방식이나 전체적인 서비스 흐름을 이해하고 싶을 때 유용합니다. +- [docs/REACT_QUERY.md](./REACT_QUERY.md) - 서버 상태 관리를 위한 React Query 라이브러리 사용 가이드. API 데이터 호출, 캐싱, 동기화 등을 효율적으로 처리하고 싶을 때 참고하세요. +- [docs/TESTING.md](./TESTING.md) - 테스트 작성 가이드. 테스트 기법(유닛/통합), MSW 인프라, 파일 네이밍 컨벤션, 패턴별 코드 예시 등을 확인할 수 있습니다. + +### 테스트 + +- [TEST_ROADMAP.md](../TEST_ROADMAP.md) - 테스트 커버리지 확장 로드맵. FSD 레이어별 현황, 6단계 Phase 진행 상태, 잔여 작업 목록을 관리합니다. ## src diff --git a/docs/REACT_QUERY.md b/docs/REACT_QUERY.md index 4fa855f9..12a3a9ff 100644 --- a/docs/REACT_QUERY.md +++ b/docs/REACT_QUERY.md @@ -1,8 +1,10 @@ # react-query 컨벤션 +> Last Update (26.03.02) + ### 개요 -react-query-wrapper - react-query의 추상화 레이어 +react-query-wrapper - react-query의 추상화 레이어 (TanStack Query v5) ### 1. react query wrapper는 react query와 백엔드 서비스의 연동 레이어라는 단일 책임만 가지게 하며, 실제 api를 활용한 로직은 application service 로직을 구현할 hook 혹은 컴포넌트에서 처리한다. @@ -29,13 +31,16 @@ function SomeFeature() { ### 2. react-query-wrapper 파일 네이밍 규칙은 아래와 같이 한다. -- query - `use[Entity]Query.ts` - - `ex) usePostsQuery.ts` - - `ex) usePostsInfiniteQuery.ts` - - `ex) usePostsSuspenseQuery.ts` -- mutation - `use[Entity][Action]Mutation.ts` - - `ex) usePostCreateMutation.ts` - - `ex) usePostUpdateMutation.ts` +kebab-case 파일명에 `.query.ts` / `.mutation.ts` 접미사를 사용한다. + +- query - `use-{동작}-{엔티티}.query.ts` + - `ex) use-fetch-playlists.query.ts` + - `ex) use-fetch-playlist-tracks.query.ts` + - `ex) use-search-musics.query.ts` +- mutation - `use-{동작}-{엔티티}.mutation.ts` + - `ex) use-create-playlist.mutation.ts` + - `ex) use-enter-partyroom.mutation.ts` + - `ex) use-block-crew.mutation.tsx` ### 3. query 의 옵션을 잘 활용한다. @@ -47,9 +52,17 @@ staleTime과 gcTime을 몇으로 설정할지에 대한 명확한 기준은 없 - ⭐ _[공식문서의 설명](https://tanstack.com/query/latest/docs/react/guides/advanced-ssr)에 따라, 서버 측 렌더링 시에는 staleTime을 0이 아닌 값으로 설정해야 한다._ - etc.. -#### 3-2. staleTime의 기본값이 5분이므로, refetchOnWindowFocus 옵션을 default true 로 놓되, 필요할 땐 적절히 활용한다. +#### 3-2. 프로젝트 기본 설정을 참고하여 옵션을 조정한다. + +프로젝트의 `QueryClient` 기본값 (`src/app/_providers/react-query.provider.tsx`): + +| 옵션 | 기본값 | 비고 | +| ---------------------- | ----------------------------- | ----------------------------------------- | +| `staleTime` | 5분 (`FIVE_MINUTES`) | `src/shared/config/time.ts` 상수 사용 | +| `refetchOnWindowFocus` | `false` | 필요 시 개별 쿼리에서 `true`로 오버라이드 | +| `retry` | dev: `false` / prod: 최대 3회 | 인증 에러(`isAuthError`)는 즉시 중단 | -외부의 요인, 사용자의 액션 등으로 계속해서 변경되어 캐시가 무의미한 데이터인 경우, refetchOnWindowFocus 옵션을 true 로 설정하여 적절한 시점에 데이터를 갱신할 수 있도록 한다. +외부의 요인, 사용자의 액션 등으로 계속해서 변경되어 캐시가 무의미한 데이터인 경우, 개별 쿼리에서 `refetchOnWindowFocus: true`를 설정하여 적절한 시점에 데이터를 갱신할 수 있도록 한다. ### 4. 제네릭을 활용한다. diff --git a/docs/README.md b/docs/README.md index 9636a4d7..b848d918 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,10 @@ yarn dev And then, open `https://localhost:3000` in your browser. +## Testing + +Please refer to [Testing Guide](./TESTING.md). + ## Contributing Please refer to [Contributing](./CONTRIBUTING.md). diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md new file mode 100644 index 00000000..0dcb7123 --- /dev/null +++ b/docs/TECH_DEBT.md @@ -0,0 +1,113 @@ +# Technical Debt Registry + +> 코드 품질·유지보수성 관점에서 개선이 필요한 항목을 추적한다. +> 각 항목은 방향이 명확하여 ADR 없이 바로 실행 가능한 것들이다. + +--- + +## 우선순위 범례 + +| 등급 | 의미 | +| ------------- | ----------------------------------- | +| P0 — Critical | 프로덕션 안정성·보안에 직접 영향 | +| P1 — High | 개발 생산성·코드 품질에 상당한 영향 | +| P2 — Medium | 개선하면 좋지만 당장 문제는 아님 | + +--- + +## P0 — Critical + +### TD-001: WebSocket 재연결에 지수 백오프 없음 + +- **파일**: `src/shared/api/websocket/client.ts:55` +- **현상**: `reconnectDelay: 5000` 고정값 사용. 서버 장애 시 모든 클라이언트가 동시에 5초 간격으로 재연결을 시도하여 thundering herd 문제 발생 가능 +- **개선 방향**: 지수 백오프 + jitter + 최대 재시도 횟수 적용 +- **참고**: ADR-002 + +### TD-002: WebSocket 타입 백엔드 미검증 + +- **파일**: `src/shared/api/websocket/types/partyroom.ts` + - `:81` — `// TODO: 임의로 작성. 실제 타입 확인 필요` (PlaybackSkipEvent) + - `:143` — `// FIXME: 맘대로 작성함. api 측과 enum 일치할지 확인 필요` (PARTYROOM_CLOSE) +- **현상**: 프론트엔드가 임의로 정의한 타입이 백엔드와 불일치할 수 있음 +- **개선 방향**: 백엔드 API 스펙과 대조 후 타입 확정 + +### TD-003: 에러 객체 직접 변이 (mutation) + +- **파일**: `src/shared/api/http/client/interceptors/response.ts:53` +- **현상**: `e.response.data = e.response.data.data` — AxiosError 객체를 직접 변이 +- **개선 방향**: WeakMap 등으로 원본 에러 객체를 보존하면서 unwrap된 데이터를 별도로 관리 +- **참고**: ADR-005 + +--- + +## P1 — High + +### TD-004: STOMP 하트비트 커스텀 구현 → 내장 기능 전환 + +- **파일**: `src/shared/api/websocket/client.ts:158-183` +- **현상**: GCP 60초 타임아웃 우회를 위해 4초 간격 커스텀 heartbeat(`/pub/heartbeat`)를 `setInterval`로 구현. STOMP 프로토콜 내장 heartbeat를 사용하지 않음 +- **개선 방향**: STOMP built-in heartbeat로 마이그레이션 (Slack 논의 참조) +- **참고**: 코드 내 주석에 마이그레이션 계획 기재됨 + +### TD-005: TODO/FIXME 41개 미해결 + +- **현상**: `src/` 전체에 41개의 TODO/FIXME가 32개 파일에 분포 +- **주요 파일**: + - `src/shared/api/websocket/types/partyroom.ts` — 타입 검증 2건 + - `src/shared/api/http/types/@enums.ts` — enum 스크립트 2건 + - `src/entities/partyroom-client/lib/subscription-callbacks/` — 구현 누락 1건 + - `src/entities/current-partyroom/model/` — 상태 관리 1건 +- **개선 방향**: 각 TODO를 GitHub Issue로 전환하여 추적하거나, 해결 후 제거 + +### TD-006: `@enums.ts` 자동 생성 스크립트 미작동 + +- **파일**: `src/shared/api/http/types/@enums.ts` + - `:45` — `// FIXME: enum auto generation 스크립트 수정 필요` + - `:55` — `// TODO: 현재 스크립트가 숫자 잡아내지 못해서 수동 수정. 스크립트 수정 필요` +- **현상**: enum 자동 생성 스크립트가 숫자형 enum을 처리하지 못하여 수동 관리 중 +- **개선 방향**: 스크립트 수정 또는 OpenAPI codegen 도입 + +### TD-007: `usePlaybackSkipCallback` 빈 구현 + +- **파일**: `src/entities/partyroom-client/lib/subscription-callbacks/use-playback-skip-callback.hook.ts:3-6` +- **현상**: 콜백 함수가 `// TODO: implementation`만 있고 빈 상태 +- **개선 방향**: 재생 스킵 이벤트 수신 시 UI 상태 반영 로직 구현 + +--- + +## P2 — Medium + +### TD-008: Wallet Provider가 FCP를 차단 + +- **파일**: `src/app/_providers/wallet.provider.tsx:10-17` +- **현상**: SSR 하이드레이션 불일치 방지를 위해 `mounted` 상태가 `true`가 될 때까지 children을 렌더링하지 않음. 지갑 기능이 필요 없는 페이지까지 FCP가 지연됨 +- **개선 방향**: 지갑 기능이 필요한 페이지에서만 Provider를 렌더링하거나, `dynamic(() => import(...), { ssr: false })` 적용 +- **참고**: ADR-008 + +### TD-009: `useStores` 2단계 셀렉터 보일러플레이트 + +- **파일**: `src/entities/current-partyroom/lib/use-chat.hook.ts:11-12` 외 다수 +- **현상**: 매번 `const { useX } = useStores(); const value = useX(state => state.field);` 두 단계 필요 +- **개선 방향**: 커스텀 훅으로 1단계 접근 패턴 제공 (예: `useCurrentPartyroomField('chat')`) +- **참고**: ADR-001 + +### TD-010: `index.ui.ts` ESLint 경계 미강제 + +- **파일**: `src/shared/ui/index.ui.ts`, 각 feature/entity의 `index.ui.ts` +- **현상**: `index.ui.ts`에서만 `'use client'` 컴포넌트를 export하는 컨벤션이 있지만, ESLint 룰로 강제되지 않아 실수로 `index.ts`에서 CSR 컴포넌트를 export할 수 있음 +- **개선 방향**: 커스텀 ESLint 룰 또는 `eslint-plugin-boundaries` 설정으로 강제 +- **참고**: ADR-003, ADR-011 + +### TD-011: 이벤트 핸들러 매핑 타입 미강제 + +- **파일**: `src/entities/partyroom-client/lib/partyroom-client.ts` +- **현상**: WebSocket 구독 대상과 콜백 훅의 매핑이 수동으로 관리됨. 새 이벤트 추가 시 매핑 누락 가능 +- **개선 방향**: 타입 레벨에서 모든 구독 대상에 대응하는 핸들러가 존재하는지 강제 +- **참고**: ADR-002 + +### TD-012: API 버전 관리 부재 + +- **파일**: `src/shared/api/http/client/client.ts:12-17` +- **현상**: API 호출에 버전 정보 없음. `baseURL`이 호스트명만 포함하며 `/v1/` 등의 prefix가 없음 +- **개선 방향**: 백엔드와 협의하여 URL prefix 또는 헤더 기반 버전 관리 도입 diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..1adc7095 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,257 @@ +# 테스트 가이드 + +> Last Update (26.03.02) + +## 1. 실행 방법 + +```bash +# 전체 테스트 실행 +yarn test + +# 커버리지 없이 실행 (빠름) +npx jest --no-coverage + +# 특정 파일/패턴만 실행 +npx jest src/shared/ui --no-coverage +npx jest --testPathPattern="integration" --no-coverage + +# 타입 체크 +yarn test:type +``` + +## 2. 테스트 기법 + +프로젝트에서 사용하는 테스트 기법은 6가지로 분류됩니다. + +| 기법 | 도구 | 대상 | 예시 | +| ----------------- | ---------------------------------- | ------------------------------------- | ----------------------------------------- | +| **유닛-함수** | 직접 호출 → 반환값 단언 | 순수 함수, 유틸리티 | `categorize.test.ts` | +| **유닛-모델** | 직접 호출 → 상태/반환값 단언 | Zustand 스토어, 도메인 모델 | `current-partyroom.store.test.ts` | +| **유닛-클래스** | 인스턴스 생성 → 메서드 호출 | Singleton 서비스, 어댑터, 데코레이터 | `singleton.decorator.test.ts` | +| **유닛-훅** | `renderHook` → `act` → 반환값 단언 | 커스텀 훅 (상태, 부수효과) | `use-can-adjust-grade.hook.test.ts` | +| **유닛-컴포넌트** | RTL `render` → 이벤트 → DOM 단언 | React 컴포넌트 (props, 인터랙션) | `button.component.test.tsx` | +| **통합-MSW** | MSW + `renderHook` / 서비스 호출 | API 훅 → 서비스 → 인터셉터 → 네트워크 | `use-enter-partyroom.integration.test.ts` | + +## 3. 파일 네이밍 컨벤션 + +테스트 파일은 소스 파일과 **동일 디렉토리에 co-locate** 합니다. +서비스 통합 테스트만 `__test__/` 디렉토리를 사용합니다. + +``` +src/shared/ui/components/button/ +├── button.component.tsx # 소스 +└── button.component.test.tsx # 테스트 (co-locate) + +src/shared/api/http/services/ +├── playlists.ts # 소스 +└── __test__/ + └── playlists.integration.test.ts # 서비스 통합 테스트 +``` + +### 네이밍 패턴 + +| 유형 | 파일명 패턴 | 확장자 | +| ----------- | ---------------------------------------------- | ------ | +| 순수 함수 | `{name}.test.ts` | `.ts` | +| 모델/스토어 | `{name}.model.test.ts`, `{name}.store.test.ts` | `.ts` | +| 훅 | `{name}.hook.test.ts` | `.ts` | +| 컴포넌트 | `{name}.component.test.tsx` | `.tsx` | +| 통합 테스트 | `{name}.integration.test.ts` | `.ts` | + +## 4. MSW 통합 테스트 작성법 + +MSW(Mock Service Worker)를 사용하여 `jest.mock` 없이 실제 axios → 인터셉터 → 응답 처리 파이프라인을 검증합니다. + +### 인프라 파일 구조 + +``` +src/shared/api/__test__/ +├── jest-msw-env.ts # 커스텀 Jest 환경 (jsdom + Node.js fetch 글로벌) +├── msw-server.ts # setupServer + beforeAll/afterEach/afterAll 라이프사이클 +├── handlers.ts # 25+ 엔드포인트 핸들러 +└── test-utils.tsx # createTestQueryClient, TestWrapper, renderWithClient +``` + +### 기본 패턴: 서비스 호출 테스트 + +```typescript +// src/shared/api/__test__/msw-server.ts 를 반드시 import +import '@/shared/api/__test__/msw-server'; +import { playlistsService } from '@/shared/api/http/services'; + +describe('playlistsService', () => { + test('플레이리스트 생성 성공', async () => { + const result = await playlistsService.createPlaylist({ name: 'My List' }); + expect(result).toHaveProperty('playlistId'); + }); +}); +``` + +### 기본 패턴: React Query 훅 통합 테스트 + +```typescript +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { http, HttpResponse } from 'msw'; +import { act, waitFor } from '@testing-library/react'; +import useCreatePlaylist from './use-create-playlist.mutation'; + +const API = process.env.NEXT_PUBLIC_API_HOST_NAME; + +test('뮤테이션 성공 시 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useCreatePlaylist()); + const invalidate = jest.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ name: 'Test' }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalled(); +}); +``` + +### 에러 테스트 패턴 + +```typescript +import { server } from '@/shared/api/__test__/msw-server'; +import { http, HttpResponse } from 'msw'; +import { ErrorCode } from '@/shared/api/http/types/@shared'; + +test('API 에러 시 에러가 전파된다', async () => { + // 핸들러 오버라이드 + server.use( + http.post(`${API}v1/partyrooms/:id/enter`, () => + HttpResponse.json( + { errorCode: ErrorCode.ACTIVE_ANOTHER_ROOM, reason: 'Already in room' }, + { status: 400 } + ) + ) + ); + + const { result } = renderWithClient(() => useEnterPartyroom()); + + await act(async () => { + result.current.mutate({ partyroomId: 1 }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); +}); +``` + +### 핸들러 확장 + +새 API 엔드포인트를 테스트하려면 `handlers.ts`에 핸들러를 추가합니다. + +```typescript +// src/shared/api/__test__/handlers.ts +import { http, HttpResponse } from 'msw'; + +const BASE = 'http://localhost:8080/api/'; + +export const handlers = [ + // 기존 핸들러들... + + // 새 핸들러 추가 + http.get(`${BASE}v1/new-endpoint`, () => HttpResponse.json({ data: { items: [] } })), +]; +``` + +> 응답은 실제 API 스펙 `{ data: { ... } }` 형태로 래핑해야 합니다. +> `unwrapResponse` 인터셉터가 `response.data.data`를 추출합니다. + +## 5. 유닛 테스트 패턴 + +### Zustand 스토어 + 권한 훅 + +`useStores` 컨텍스트에 의존하는 훅 테스트 패턴입니다. + +```typescript +jest.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanClose from './use-can-close-current-partyroom.hook'; + +let store: ReturnType; + +beforeEach(() => { + jest.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as jest.Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +test('HOST는 파티룸을 닫을 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanClose()); + expect(result.current).toBe(true); +}); +``` + +### React Query 캐시 모킹 + +```typescript +const mockGetQueryData = jest.fn(); +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ getQueryData: mockGetQueryData }), +})); + +import { renderHook } from '@testing-library/react'; +import useIsNft from './use-is-nft.hook'; + +test('NFT 목록에 URI가 존재하면 true를 반환한다', () => { + mockGetQueryData.mockReturnValue([ + { resourceUri: 'https://example.com/nft1.png', available: true }, + ]); + const { result } = renderHook(() => useIsNft()); + expect(result.current('https://example.com/nft1.png')).toBe(true); +}); +``` + +### 컴포넌트 테스트 (Headless UI 사용 시) + +Headless UI 컴포넌트(`Select`, `Tab`, `Dialog` 등)를 사용하는 컴포넌트는 `ResizeObserver` mock이 필요합니다. + +```typescript +global.ResizeObserver = class ResizeObserver { + public observe() { /* noop */ } + public unobserve() { /* noop */ } + public disconnect() { /* noop */ } +} as any; + +import { render, fireEvent } from '@testing-library/react'; +import Button from './button.component'; + +test('클릭 이벤트가 발생한다', () => { + const onClick = jest.fn(); + const { getByRole } = render(); + fireEvent.click(getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); +}); +``` + +## 6. 알려진 제약사항 + +| 제약 | 설명 | 대응 | +| ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **jest-dom 미설정** | `toBeInTheDocument()` 사용 불가 | `toBeTruthy()` / `toBeFalsy()` 대체 | +| **ErrorCode 검증** | `getErrorCode()`가 enum에 없는 코드를 무시하고 `undefined` 반환 | 테스트 시 반드시 `ErrorCode` enum 값 사용 | +| **MSW 서버 import** | `msw-server.ts`를 명시적 import 해야 라이프사이클 훅 실행됨 | `server.use()` 없는 파일도 `import '@/shared/api/__test__/msw-server'` 필수 | +| **useIsNft 반환값** | `nfts && nfts.find(...)` → 데이터 없으면 `undefined` 반환 | `toBe(false)` 대신 `toBeFalsy()` 사용 | +| **ResizeObserver** | jsdom에 미구현 → Headless UI 컴포넌트 테스트 시 에러 | 테스트 상단에 글로벌 mock 추가 | + +## 7. 환경 설정 요약 + +| 항목 | 값 | +| -------------------------- | ----------------------------------------------- | +| 테스트 러너 | Jest 29 | +| 테스트 환경 | jsdom (`jest-msw-env.ts`로 fetch 글로벌 복원) | +| 트랜스파일러 | @swc/jest | +| 모듈 별칭 | `@/` → `src/` | +| MSW 버전 | v2 (Node.js `setupServer`) | +| React Testing Library | v16 | +| React Query | TanStack Query v5 | +| QueryClient 기본 staleTime | 5분 (300,000ms) | +| QueryClient 기본 retry | dev: 비활성화 / prod: 최대 3회 (인증 에러 제외) | diff --git a/docs/TEST_ROADMAP.md b/docs/TEST_ROADMAP.md new file mode 100644 index 00000000..2d7ccaed --- /dev/null +++ b/docs/TEST_ROADMAP.md @@ -0,0 +1,374 @@ +# pfplay-web 테스트 로드맵 + +> 최종 갱신: 2026-03-02 | 브랜치: `test/add-mocking-tests` + +--- + +## 1. 현재 상태 분석 + +### 1-1. 정량 지표 + +| 지표 | 초기값 | 현재 | +| --------------- | ------ | -------- | +| 테스트 스위트 | 104 | **202** | +| 테스트 케이스 | 655 | **1003** | +| 소스 모듈 총 수 | 471 | 471 | + +### 1-2. FSD 레이어별 소스 / 테스트 분포 + +| 레이어 | 소스 모듈 | 테스트 파일 | 테스트 비율 | 비율 변화 | +| -------- | --------- | ----------- | ----------- | --------- | +| shared | 215 | 91 | 42.3 % | +8.0 | +| entities | 90 | 42 | 46.7 % | +3.3 | +| features | 168 | 49 | 29.2 % | **+23.8** | +| widgets | 60 | 18 | 30.0 % | **+25.9** | +| app | 37 | 0 | 0 % | — | + +### 1-3. 레이어 × 테스트 기법 매트릭스 + +> 각 셀: `완료 / 전체` (해당 없음은 `—`, 통합-MSW는 테스트 파일 수) + +| 레이어 \ 기법 | 유닛-함수 | 유닛-모델 | 유닛-클래스 | 유닛-훅 | 유닛-컴포넌트 | 통합-MSW | +| ------------- | :-------: | :-------: | :---------: | :-------: | :-----------: | :------: | +| **shared** | 24/~50 | 0/2 | 17/~22 | **14/14** | **24/31** | 5 | +| **entities** | 1 | 12/17 | 3 | **20/31** | 1/10 | **5** | +| **features** | — | 4/4 | — | **27/74** | 0/68 | **24** | +| **widgets** | — | 2/2 | — | **2/7** | **13/13** | **1** | +| **app** | — | — | — | — | 0/7 | — | + +### 1-4. 강점 + +- **순수 함수**: 24개 파일, 높은 품질의 경계값·엣지 케이스 커버 +- **모델/스토어**: Zustand 스토어 + 도메인 모델 16/23 테스트 완료 +- **클래스**: Singleton, 데코레이터, Chat, WebSocket 등 핵심 인프라 테스트 +- **MSW 인프라**: jest-msw-env, 커스텀 resolver, handlers 등 통합 테스트 기반 구축 완료 +- **구독 콜백**: partyroom-client 이벤트 핸들러 11개 전수 테스트 +- **shared/ui**: 21개 공유 컴포넌트 테스트 완료 (폼, 레이아웃, 표시 컴포넌트) +- **권한 훅**: features 레이어 permission 훅 10개 전수 테스트 +- **통합 테스트**: 28개 MSW 통합 테스트로 서비스→인터셉터→캐시 파이프라인 검증 +- **비즈니스 훅**: enter, exit, close, adjust-grade, impose-penalty 등 핵심 흐름 전수 테스트 +- **위젯**: 서브 컴포넌트 9개 + 메인 컴포넌트 4개 + 훅 2개 테스트 완료 +- **알림 훅**: alert, grade-adjusted, penalty 알림 훅 전수 테스트 + +### 1-5. 취약점 + +- **features 컴포넌트**: 68개 중 0개 테스트 +- **app 레이어**: 테스트 0개 +- **E2E 테스트**: 없음 +- **외부 SDK 의존 훅**: Alchemy SDK, wagmi 의존 모듈은 현재 인프라로 테스트 불가 + +--- + +## 2. 목표 수립 + +### ~~Phase 1: 미테스트 모델/스토어 완성~~ `유닛-모델` — SKIP + +7개 모두 타입/인터페이스 정의만 존재. 구현체(store, adapter)는 이미 테스트 완료. +실질 커버율 **100%**. + +--- + +### Phase 2: shared/ui 핵심 컴포넌트 테스트 `유닛-컴포넌트` — ✅ 21/21 완료 + +**실적**: 21개 파일, ~123케이스 + +#### Tier 1 — 폼 컴포넌트 ✅ + +- [x] `shared/ui/components/button/button.component.tsx` +- [x] `shared/ui/components/select/select.component.tsx` +- [x] `shared/ui/components/textarea/textarea.component.tsx` +- [x] `shared/ui/components/radio-select-list/radio-select-list.component.tsx` +- [x] `shared/ui/components/form-item/form-item.component.tsx` + +#### Tier 2 — 레이아웃/내비게이션 ✅ + +- [x] `shared/ui/components/dialog/dialog.component.tsx` +- [x] `shared/ui/components/drawer/drawer.component.tsx` +- [x] `shared/ui/components/tab/tab.component.tsx` +- [x] `shared/ui/components/collapse-list/collapse-list.component.tsx` +- [x] `shared/ui/components/tooltip/tooltip.component.tsx` + +#### Tier 3 — 표시 컴포넌트 ✅ + +- [x] `shared/ui/components/tag/tag.component.tsx` +- [x] `shared/ui/components/profile/profile.component.tsx` +- [x] `shared/ui/components/loading/loading.component.tsx` +- [x] `shared/ui/components/typography/typography.component.tsx` +- [x] `shared/ui/components/back-button/back-button.component.tsx` + +#### Tier 4 — 복합 컴포넌트 ✅ (6/6) + +- [x] `shared/ui/components/menu/menu-button.component.tsx` +- [x] `shared/ui/components/menu/menu-item-panel.component.tsx` +- [x] `shared/ui/components/icon-menu/icon-menu.component.tsx` +- [x] `shared/ui/components/infinite-scroll/infinite-scroll.component.tsx` +- [x] `shared/ui/components/user-list-item/user-list-item.component.tsx` +- [x] `shared/ui/components/dj-list-item/dj-list-item.component.tsx` + +--- + +### Phase 3: shared 훅 테스트 확장 `유닛-훅` — ✅ 8/8 완료 + +**실적**: 8개 파일, 25케이스 + +- [x] `shared/lib/hooks/use-intersection-observer.hook.ts` +- [x] `shared/lib/hooks/use-portal-root.hook.ts` +- [x] `shared/lib/hooks/use-vertical-stretch.hook.ts` +- [x] `shared/lib/localization/use-change-language.hook.tsx` +- [x] `shared/lib/localization/use-languages.hook.ts` +- [x] `shared/lib/router/use-app-router.hook.ts` +- [x] `shared/ui/components/dialog/use-dialog.hook.tsx` +- [x] `shared/api/http/error/use-on-error.hook.ts` + +--- + +### Phase 4: API 통합 테스트 확장 `통합-MSW` — 38/39 + +**실적**: 25개 파일, ~94케이스 | **미완료 1건** + +#### 4-1. 파티룸 도메인 ✅ (8/8) + +- [x] `features/partyroom/enter/api/use-enter-partyroom.mutation.ts` +- [x] `features/partyroom/exit/api/use-exit-partyroom.mutation.ts` +- [x] `features/partyroom/create/api/use-create-partyroom.mutation.ts` +- [x] `features/partyroom/close/api/use-close-partyroom.mutation.ts` +- [x] `features/partyroom/edit/api/use-edit-partyroom.mutation.ts` +- [x] `features/partyroom/list/api/use-fetch-general-partyrooms.query.ts` +- [x] `features/partyroom/list/api/use-fetch-main-partyroom.query.ts` (partyroom-queries 통합) +- [x] `features/partyroom/get-summary/api/use-fetch-partyroom-detail-summary.query.tsx` (partyroom-queries 통합) + +#### 4-2. DJ / 재생 도메인 ✅ (9/9) + +- [x] `features/partyroom/list-djing-queue/api/use-fetch-djing-queue.query.ts` +- [x] `features/partyroom/lock-djing-queue/api/use-lock-djing-queue.mutation.ts` (dj-management 통합) +- [x] `features/partyroom/unlock-djing-queue/api/use-unlock-djing-queue.mutation.ts` (dj-management 통합) +- [x] `features/partyroom/delete-dj-from-queue/api/use-delete-dj-from-queue.mutation.ts` (dj-management 통합) +- [x] `features/partyroom/skip-playback/api/use-skip-playback.mutation.tsx` (dj-management 통합) +- [x] `features/partyroom/evaluate-current-playback/api/use-evaluate-current-playback.mutation.ts` +- [x] `features/partyroom/list-playback-histories/api/use-fetch-playback-histories.query.ts` (dj-queries 통합) +- [x] `widgets/partyroom-djing-dialog/api/use-register-me-to-queue.mutation.ts` +- [x] `widgets/partyroom-djing-dialog/api/use-unregister-me-from-queue.mutation.ts` + +#### 4-3. 크루 / 권한 도메인 ✅ (7/7) + +- [x] `features/partyroom/adjust-grade/api/use-adjust-grade.mutation.ts` +- [x] `features/partyroom/block-crew/api/use-block-crew.mutation.tsx` +- [x] `features/partyroom/unblock-crew/api/use-unblock-crew.mutation.tsx` +- [x] `features/partyroom/impose-penalty/api/use-impose-penalty.mutation.tsx` (penalty 통합) +- [x] `features/partyroom/lift-penalty/api/use-lift-penalty.mutation.tsx` (penalty 통합) +- [x] `features/partyroom/list-penalties/api/use-fetch-penalties.query.ts` +- [x] `features/partyroom/list-my-blocked-crews/api/use-fetch-my-blocked-crews.query.ts` + +#### 4-4. 플레이리스트 도메인 ✅ (6/6) + +- [x] `features/playlist/add-tracks/api/use-add-playlist-track.mutation.ts` +- [x] `features/playlist/edit/api/use-update-playlist.mutation.ts` +- [x] `features/playlist/list/api/use-fetch-playlists.query.ts` +- [x] `features/playlist/list-tracks/api/use-fetch-playlist-tracks.query.ts` +- [x] `features/playlist/remove/api/use-remove-playlist.mutation.ts` +- [x] `features/playlist/move-track-to-the-other-playlist/api/use-change-track-order.mutation.ts` + +#### 4-5. 인증 / 프로필 도메인 (8/9) + +- [x] `features/sign-in/by-guest/api/use-sign-in.mutation.ts` +- [x] `features/sign-out/api/use-sign-out.mutation.ts` +- [x] `features/edit-profile-bio/api/use-update-my-bio.mutation.ts` +- [x] `features/edit-profile-avatar/api/use-update-my-avatar.mutation.ts` +- [x] `features/edit-profile-avatar/api/use-fetch-avatar-bodies.query.ts` (avatar 통합) +- [x] `features/edit-profile-avatar/api/use-fetch-avatar-faces.query.ts` (avatar 통합) +- [x] `entities/me/api/use-prefetch-me.query.ts` +- [ ] `entities/wallet/api/use-fetch-nfts.query.ts` +- [x] `entities/wallet/api/use-update-my-wallet.mutation.ts` + +#### 4-X. 미달성 사유 + +| 항목 | 사유 | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `use-fetch-nfts.query.ts` | **외부 SDK 직접 호출**. 이 훅은 내부 axios 인스턴스(`baseURL: http://localhost:8080/api/`)를 사용하지 않고, Alchemy SDK(`alchemy.nft.getNftsForOwner`)를 통해 `https://eth-mainnet.g.alchemy.com/` 에 직접 HTTP 요청을 보낸다. MSW는 우리 API 서버의 엔드포인트만 핸들러로 등록하고 있으며, Alchemy의 응답 스키마(`OwnedNftsResponse`)를 역설계하여 핸들러를 작성하면 Alchemy SDK 버전 업그레이드 시 깨지는 취약한 테스트가 된다. 또한 이 훅은 `Nft.refineList(ownedNfts)` 후처리를 하는데, `refineList` 자체는 모델 레이어에서 단위 테스트하는 것이 적합하다. | + +--- + +### Phase 5: entities/features 레이어 훅 테스트 `유닛-훅` — 33/37 + +**실적**: 32개 파일, ~100케이스 | **미완료 4건** + +#### 5-1. entities 훅 (9/13) + +- [ ] `entities/current-partyroom/lib/use-chat.hook.ts` +- [x] `entities/current-partyroom/lib/use-remove-current-partyroom-caches.hook.ts` +- [x] `entities/current-partyroom/lib/alerts/use-alert.hook.tsx` +- [ ] `entities/current-partyroom/lib/alerts/use-alerts.hook.tsx` +- [x] `entities/current-partyroom/lib/alerts/use-grade-adjusted-alert.hook.tsx` +- [x] `entities/current-partyroom/lib/alerts/use-penalty-alert.hook.tsx` +- [x] `entities/me/api/use-fetch-me-async.ts` +- [ ] `entities/me/api/use-get-my-service-entry.ts` +- [x] `entities/me/lib/use-is-guest.hook.tsx` +- [x] `entities/wallet/lib/use-global-wallet-sync.hook.ts` +- [x] `entities/wallet/lib/use-is-nft.hook.ts` +- [x] `entities/wallet/lib/use-is-wallet-linked.hook.ts` +- [ ] `entities/wallet/lib/use-nfts.hook.ts` + +#### 5-2. features 권한 훅 ✅ (10/10) + +- [x] `features/partyroom/adjust-grade/api/use-can-adjust-grade.hook.ts` +- [x] `features/partyroom/close/api/use-can-close-current-partyroom.hook.ts` +- [x] `features/partyroom/edit/api/use-can-edit-current-partyroom.hook.ts` +- [x] `features/partyroom/impose-penalty/api/use-can-impose-penalty.hook.ts` +- [x] `features/partyroom/impose-penalty/api/use-can-remove-chat-message.hook.ts` (동일 파일) +- [x] `features/partyroom/lift-penalty/api/use-can-lift-penalty.hook.ts` +- [x] `features/partyroom/list-penalties/api/use-can-view-penalties.hook.ts` +- [x] `features/partyroom/lock-djing-queue/lib/use-can-lock-djing-queue.hook.ts` +- [x] `features/partyroom/unlock-djing-queue/lib/use-can-unlock-djing-queue.hook.ts` +- [x] `features/partyroom/delete-dj-from-queue/lib/use-can-delete-dj-from-queue.hook.ts` + +#### 5-3. features 비즈니스 훅 ✅ (14/14) + +- [x] `features/partyroom/enter/lib/use-enter-partyroom.ts` +- [x] `features/partyroom/enter/lib/use-partyroom-enter-error-alerts.ts` +- [x] `features/partyroom/exit/lib/use-exit-partyroom.ts` +- [x] `features/partyroom/close/lib/use-close-partyroom.hook.tsx` +- [x] `features/partyroom/list-crews/lib/use-crews.hook.ts` +- [x] `features/partyroom/adjust-grade/lib/use-adjust-grade.hook.tsx` +- [x] `features/partyroom/impose-penalty/lib/use-impose-penalty.hook.tsx` +- [x] `features/partyroom/block-crew/lib/use-block-crew.hook.tsx` +- [x] `features/partyroom/lock-djing-queue/lib/use-lock-djing-queue.hook.ts` +- [x] `features/partyroom/unlock-djing-queue/lib/use-unlock-djing-queue.hook.ts` +- [x] `features/partyroom/delete-dj-from-queue/lib/use-delete-dj-from-queue.hook.ts` +- [x] `features/partyroom/list-my-blocked-crews/lib/use-is-blocked-crew.hook.ts` +- [x] `features/playlist/djing-guide/ui/use-djing-guide.hook.tsx` +- [x] `features/sign-out/lib/use-sign-out.hook.ts` + +#### 5-X. 미달성 사유 + +| 항목 | 분류 | 사유 | +| ----------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `use-chat.hook.ts` | 커스텀 pub/sub 의존 | Zustand와 별개의 구독 메커니즘(`chat.addMessageListener/removeMessageListener`)을 사용한다. `chat` 객체는 `@/shared/lib/chat`의 `Chat` 클래스 인스턴스로, 내부적으로 메시지 큐 + 리스너 맵을 관리한다. 이 훅을 테스트하려면 `Chat` 클래스의 전체 pub/sub 계약을 모방하는 목 객체를 생성해야 하는데, `Chat` 클래스 자체가 이미 클래스 레벨 단위 테스트(`chat.test.ts`)에서 검증되고 있다. 훅의 로직은 `addMessageListener` → `setMessages(chat.getMessages())` → 클린업 시 `removeMessageListener` 뿐이므로, 실질적 비즈니스 로직은 `Chat` 클래스에 위임되어 있다. | +| `use-alerts.hook.tsx` | 순수 합성 훅 | 함수 본문이 `useGradeAdjustedAlert()` + `usePenaltyAlert()` 호출 2줄뿐이다. 두 서브 훅 모두 개별적으로 전수 테스트 완료. 이 합성 훅을 별도 테스트하면 "두 함수가 호출되는지" 만 검증하는 **글루 코드 테스트 안티패턴**에 해당하며, 리팩터링 시 테스트 가치 없이 유지비용만 발생한다. | +| `use-get-my-service-entry.ts` | 얇은 래퍼 | `useFetchMeAsync()` → `Me.serviceEntry(me)` 를 호출하는 래퍼다. `useFetchMeAsync`는 MSW 통합 테스트 완료(`use-fetch-me-async.test.ts`), `Me.serviceEntry`는 모델 단위 테스트 완료(`me.model.test.ts`). 이 훅에 고유한 분기 로직은 `catch` 블록에서 `Me.serviceEntry(null)`을 반환하는 것뿐이며, 이는 `serviceEntry(null) → '/'`로 이미 모델 테스트에서 커버된다. | +| `use-nfts.hook.ts` | 외부 SDK 의존 | `wagmi`의 `useAccount()` 훅과 `useFetchNfts`(Alchemy SDK)에 동시 의존한다. `useAccount`는 wagmi의 `WagmiConfig` 프로바이더가 필요하며, 이 프로바이더는 Ethereum JSON-RPC 클라이언트 설정(`chains`, `transports`)을 요구한다. 현재 Jest 환경에는 wagmi 프로바이더 인프라가 없고, `useFetchNfts` 역시 Phase 4에서 미달성된 것과 동일한 Alchemy SDK 의존 문제를 가진다. wagmi mock 인프라 구축은 블록체인 연동 전용 테스트 계층에서 별도 진행해야 한다. | + +--- + +### Phase 6: widgets 레이어 테스트 `유닛-컴포넌트` `유닛-훅` — 15/19 + +**실적**: 15개 파일, ~52케이스 | **미완료 4건** + +#### 6-1. 위젯 서브 컴포넌트 ✅ (9/9) + +- [x] `widgets/partyroom-display-board/ui/parts/action-button.component.tsx` +- [x] `widgets/partyroom-display-board/ui/parts/video-title.component.tsx` +- [x] `widgets/partyroom-display-board/ui/parts/notice.component.tsx` +- [x] `widgets/partyroom-display-board/ui/parts/add-tracks-button.component.tsx` +- [x] `widgets/partyroom-chat-panel/ui/authority-headset.component.tsx` +- [x] `widgets/partyroom-chat-panel/ui/chat-item.component.tsx` +- [x] `widgets/partyroom-djing-dialog/ui/empty-body.component.tsx` +- [x] `widgets/partyroom-party-list/ui/panel-header.component.tsx` +- [x] `widgets/music-preview-player/ui/dim-overlay.component.tsx` + +#### 6-2. 위젯 메인 컴포넌트 (4/8) + +- [ ] `widgets/partyroom-chat-panel/ui/partyroom-chat-panel.component.tsx` +- [x] `widgets/partyroom-crews-panel/ui/partyroom-crews-panel.component.tsx` +- [x] `widgets/partyroom-display-board/ui/display-board.component.tsx` +- [ ] `widgets/partyroom-djing-dialog/ui/dialog.component.tsx` +- [ ] `widgets/partyroom-detail/ui/main-panel.component.tsx` +- [ ] `widgets/my-playlist/ui/my-playlist.component.tsx` +- [x] `widgets/layouts/ui/header.component.tsx` +- [x] `widgets/sidebar/ui/sidebar.component.tsx` + +#### 6-3. 위젯 훅 ✅ (2/2) + +- [x] `widgets/partyroom-avatars/lib/use-assign-avatar-positions.hook.ts` +- [x] `widgets/partyroom-avatars/lib/use-avatar-cluster.hook.ts` + +#### 6-X. 미달성 사유 + +| 항목 | 외부 의존 수 | 사유 | +| ------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `partyroom-chat-panel.component.tsx` | 훅 13개 + 내부 훅 1개 | `useAdjustGrade`, `useCanAdjustGrade`, `useCanRemoveChatMessage`, `useCanImposePenalty`, `useRemoveChatMessage`, `useImposePenalty`, `useBlockCrew`, `useIsBlockedCrew`, `useVerticalStretch`, `useCurrentPartyroomChat`, `useStores`, `useChatMessagesScrollManager`, `useI18n` 등 13개 훅에 의존한다. 각 채팅 메시지마다 `DisplayOptionMenuOnHoverListener`를 통해 6개 조건부 메뉴 항목(등급 조정, 삭제, 채팅 금지, 강퇴, 영구 추방, 차단)을 권한에 따라 동적 렌더링한다. 내부에 `useTempChatBanTimer`(30초 타이머 기반 채팅 금지)가 있어 시간 의존적 로직도 포함된다. 13개 훅을 모두 mock하면 리팩터링 시 모든 mock이 깨지는 **취약한 테스트(fragile test)**가 되며, 사용자 인터랙션(hover → 메뉴 표시 → 클릭 → 액션 실행)은 E2E 테스트가 적합하다. | +| `dialog.component.tsx` (djing) | 훅 1개 + Context 2개 | `useFetchDjingQueue` React Query 훅에 의존하며, `PartyroomIdContext`와 `DjingQueueContext` 두 개의 Context Provider로 자식을 감싼다. 컴포넌트 자체 로직은 단순하나(`djingQueue.djs.length`에 따라 `Body`/`EmptyBody` 분기), `Dialog` 컴포넌트가 Headless UI의 포탈 렌더링을 사용하여 jsdom 환경에서 포탈 컨테이너 설정이 필요하다. 하위 컴포넌트 `Body`와 `EmptyBody`는 이미 개별 테스트 완료. 이 래퍼의 테스트는 Headless UI의 Dialog 동작을 검증하는 것에 가까워 비용 대비 효용이 낮다. | +| `main-panel.component.tsx` | 훅 10개 + Next.js 라우터 | `useI18n`, `useParams`(Next.js), `usePanelController`(Context), `useFetchDjingQueue`, `useFetchPartyroomDetailSummary`(React Query 2개), `useSharePartyroom`, `useOpenSettingDialog`, `useCanEditCurrentPartyroom`, `useCloseCurrentPartyroom`, `useCanCloseCurrentPartyroom` 등 10개 훅에 의존한다. Next.js의 `useParams`는 `next/navigation`의 라우터 컨텍스트(`AppRouterContext`)가 필요하며, Jest에서는 `jest.mock('next/navigation')`으로 우회해야 한다. 2개의 React Query 훅은 MSW 환경 + `QueryClientProvider`가 필요하고, `usePanelController`는 커스텀 Context Provider가 필요하다. 로딩/데이터/권한에 따른 조건부 렌더링이 4단계로 중첩되어 있어, 유의미한 테스트를 작성하려면 MSW + Next.js 라우터 + Context Provider 3종을 조합한 통합 테스트 환경이 필요하다. | +| `my-playlist.component.tsx` | 훅 3개 + Feature 컴포넌트 6개 + useEffect 3개 | `useI18n`, `useStores`(UIState), `usePlaylistAction`(entity 훅)에 의존하고, `AddPlaylistButton`, `AddTracksToPlaylist`, `Playlists`, `EditablePlaylists`, `TracksInPlaylist`, `RemovePlaylistButton` 등 6개 Feature 컴포넌트를 직접 JSX에 포함한다. 3개의 `useEffect`가 상태 동기화를 담당하는데: (1) 편집 모드 해제 시 `removeTargets` 초기화, (2) 드로워 닫힘 시 편집 모드 해제, (3) `playlists` 변경 시 `selectedPlaylist` 동기화. 이 상태 전이 로직은 3가지 뷰 모드(목록/편집/트랙 상세)를 렌더링 조건부로 전환하는데, 단위 테스트에서 이 전환을 검증하려면 6개 Feature 컴포넌트를 모두 mock하고 `useEffect` 발화 순서를 제어해야 한다. Drawer 열기/닫기 + 모드 전환 + 항목 선택은 사용자 시나리오 기반 E2E 테스트에 적합하다. | + +--- + +## 3. 우선순위 매트릭스 + +| Phase | 기법 | 상태 | 완료 항목 | 미완료 | +케이스 | +| --------------------------- | ---------------- | -------- | ----------------- | ------ | ------- | +| ~~1. 모델/스토어~~ | 유닛-모델 | SKIP | — | — | — | +| **2. shared/ui** | 유닛-컴포넌트 | ✅ 21/21 | 21파일 ~123케이스 | 0 | +123 | +| **3. shared 훅** | 유닛-훅 | ✅ 8/8 | 8파일 25케이스 | 0 | +25 | +| **4. API 통합** | 통합-MSW | 38/39 | 25파일 ~94케이스 | 1 | +94 | +| **5. entities/features 훅** | 유닛-훅 | 33/37 | 32파일 ~100케이스 | 4 | +100 | +| **6. widgets** | 유닛-컴포넌트/훅 | 15/19 | 15파일 ~52케이스 | 4 | +52 | + +**총 신규**: +98파일, +348케이스 (655 → 1003) + +### 미완료 항목 요약 + +| 미완료 분류 | 건수 | 대표 사유 | +| ----------------------------- | ----- | ----------------------------------------------------------------------- | +| 외부 SDK 의존 (Alchemy/wagmi) | 2 | MSW 범위 밖. 별도 인프라 필요 | +| 순수 합성/래퍼 훅 | 2 | 하위 의존성이 이미 전수 테스트됨. 글루 코드 테스트 안티패턴 | +| 고복잡도 위젯 (훅 10개 이상) | 3 | mock 다수로 인한 취약한 테스트. E2E 적합 | +| 커스텀 pub/sub 의존 | 1 | Chat 클래스 내부 계약에 결합. 클래스 레벨에서 이미 검증 | +| Headless UI 포탈 래퍼 | 1 | 하위 컴포넌트 이미 테스트. 래퍼 자체는 UI 프레임워크 동작 검증에 가까움 | +| **합계** | **9** | | + +### 최종 목표 지표 + +| 지표 | 초기 | 현재 | 이론적 최대 | 미달 사유 | +| ------------------- | ---- | --------- | ----------- | ----------------------- | +| 테스트 케이스 | 655 | **1003** | ~1030 | 외부 SDK + E2E 영역 | +| shared/ui 커버율 | 3/31 | **24/31** | 24/31 | — | +| shared 훅 커버율 | 6/14 | **14/14** | 14/14 | — | +| API 훅 통합 | 4/39 | **38/39** | 38/39 | Alchemy SDK 1건 | +| features 훅 커버율 | 0/74 | **27/74** | ~27/74 | 대부분 컴포넌트 전용 훅 | +| widgets 테스트 비율 | 4.1% | **30.0%** | ~33% | 고복잡도 4건 | + +--- + +## 4. 인프라 체크리스트 + +### 완료 + +- [x] MSW v2 설치 및 구성 +- [x] 커스텀 Jest 환경 (`jest-msw-env.ts`) — jsdom + Node.js fetch 글로벌 +- [x] 커스텀 Jest resolver (`jest.resolver.js`) — MSW exports 해결 +- [x] `transformIgnorePatterns` — ESM → CJS 변환 (msw, d3-force 등) +- [x] MSW 핸들러 (`handlers.ts`) — 플레이리스트, 유저, 파티룸, 크루, DJ, 인증, 페널티 +- [x] 테스트 유틸 (`test-utils.tsx`) — QueryClient, renderWithClient +- [x] MSW 서버 라이프사이클 (`msw-server.ts`) +- [x] ResizeObserver 글로벌 mock (Headless UI 호환) + +### 미완료 + +- [ ] `@testing-library/jest-dom` 도입 (현재 `toBeTruthy()` 대체 사용) +- [ ] wagmi 테스트 인프라 — WagmiConfig mock provider, 체인/트랜스포트 설정 +- [ ] Storybook 도입 (컴포넌트 시각적 테스트) +- [ ] E2E 테스트 프레임워크 선정 (Playwright / Cypress) +- [ ] CI 파이프라인 테스트 자동화 +- [ ] 커버리지 리포트 CI 연동 (codecov / coveralls) +- [ ] 커버리지 임계값 설정 (jest `coverageThreshold`) + +--- + +## 5. 실행 원칙 + +1. **Phase 순서 준수**: 낮은 난이도 + 높은 ROI 항목부터 진행 +2. **도메인 단위 작업**: 한 도메인(파티룸, 플레이리스트 등)의 테스트를 한번에 완성 +3. **MSW 핸들러 확장 → 통합 테스트** 순서: handlers.ts를 먼저 확장 후 테스트 작성 +4. **기존 패턴 유지**: 현재 테스트 파일의 네이밍, 구조, assertion 패턴 준수 +5. **Phase별 커밋**: 각 Phase 또는 서브Phase 완료 시 커밋 + +--- + +## 6. 알려진 제약사항 + +- **jest-dom 미설정**: `toBeInTheDocument()` 사용 불가 → `toBeTruthy()` / `toBeFalsy()` 대체 +- **ErrorCode 검증**: `getErrorCode()`가 enum에 없는 코드를 무시 → 테스트 시 `ErrorCode` enum 값만 사용 +- **SWC 미사용 import 제거**: `import { server } from 'msw-server'`에서 server를 사용하지 않으면 SWC가 import를 제거함 → side-effect import `import 'msw-server'` 사용 +- **useIsNft 반환값**: `nfts && nfts.find(...)` → undefined 데이터 시 `undefined` 반환 (not `false`) +- **d3-force ESM**: `transformIgnorePatterns`에 d3 패키지 포함 필요 +- **Headless UI 포탈**: Dialog/Drawer 등 포탈 기반 컴포넌트는 jsdom에서 포탈 컨테이너 설정 없이 렌더링 불안정 diff --git a/docs/adr/001-zustand-context-di.md b/docs/adr/001-zustand-context-di.md new file mode 100644 index 00000000..5fdc18ce --- /dev/null +++ b/docs/adr/001-zustand-context-di.md @@ -0,0 +1,43 @@ +# ADR-001: 상태 관리로 Zustand + Context DI 패턴 채택 + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +파티룸에 진입하면 크루 목록, 재생 상태, DJ 큐, 채팅 등 실시간 상태를 여러 위젯에서 공유해야 한다. 동시에 파티룸 퇴장 시 모든 상태를 깔끔하게 초기화해야 하므로, 스토어의 **lifecycle 제어**가 핵심 요구사항이었다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ------------------------ | ------------------------------- | ------------------------------------------- | +| Redux Toolkit | 생태계 풍부, DevTools | 보일러플레이트 과다, 이 규모에 과잉 | +| Zustand 전역 싱글턴 | 간단, 경량 | 스토어 초기화가 수동, lifecycle 제어 불가 | +| **Zustand + Context DI** | lifecycle이 React tree와 동기화 | 2단계 selector 패턴 필요 | +| Jotai / Recoil | atom 단위 세분화 | 파티룸 전체 상태를 한번에 초기화하기 어려움 | + +## 결정 + +**Zustand + Context DI** 패턴을 채택한다. + +`StoresProvider`에서 `createCurrentPartyroomStore()`로 스토어 인스턴스를 생성하고, `useStores()` 훅으로 하위 컴포넌트에 주입한다. 파티룸 퇴장 시 Provider가 언마운트되면 스토어도 함께 파괴된다. + +``` +StoresProvider (mount/unmount = store create/destroy) + └─ useStores() → { useCurrentPartyroom, useUIState } + └─ useCurrentPartyroom(selector) → 필요한 상태만 구독 +``` + +### 핵심 파일 + +- `src/app/_providers/stores.provider.tsx` — 스토어 생성 및 Context 제공 +- `src/shared/lib/store/stores.context.tsx` — useStores 훅 +- `src/entities/current-partyroom/model/current-partyroom.store.ts` — 파티룸 스토어 + +## 결과 + +- **(+)** 스토어 lifecycle이 React tree와 자연스럽게 동기화 +- **(+)** 테스트에서 `jest.mock('stores.context')` 한 줄로 격리 가능 +- **(+)** 파티룸 퇴장 시 별도 reset 로직 불필요 +- **(-)** `useStores(s => s.useCurrentPartyroom(selector))` 형태의 2단계 셀렉터가 다소 장황 +- **(-)** Provider 외부에서 스토어 접근 시 `getState()` 패턴 필요 diff --git a/docs/adr/002-websocket-callback-hooks.md b/docs/adr/002-websocket-callback-hooks.md new file mode 100644 index 00000000..b21ab88d --- /dev/null +++ b/docs/adr/002-websocket-callback-hooks.md @@ -0,0 +1,50 @@ +# ADR-002: WebSocket 구독을 콜백 훅 패턴으로 분리 + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +STOMP WebSocket 구독 시 채팅, 크루 변동, 등급 변경, 패널티, 반응 집계, 재생 상태 등 10종 이상의 이벤트를 처리해야 한다. 하나의 핸들러에 모으면 파일이 비대해지고, 이벤트 타입별 테스트가 어렵다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ----------------------- | -------------------------------- | ---------------------------------- | +| 단일 핸들러 switch-case | 구조 단순 | 코드 비대, 단일 파일 수백 줄 | +| Event emitter 패턴 | 느슨한 결합 | 타입 안전성 약함, 구독 추적 어려움 | +| **개별 콜백 훅** | 훅 단위 테스트 용이, 관심사 분리 | 훅 수가 많아짐 | + +## 결정 + +**이벤트 타입별 개별 콜백 훅**(`use-*-callback.hook.ts`)으로 분리한다. + +`handleSubscriptionEvent`가 메시지 타입으로 디스패치하고, 각 콜백 훅이 Zustand 스토어 업데이트를 담당한다. + +``` +STOMP message 수신 + → handleSubscriptionEvent(type, payload) + → useChatCallback (채팅 메시지) + → useCrewGradeCallback (등급 변경) + → useCrewPenaltyCallback (패널티 부과) + → useCrewProfileCallback (프로필 변경) + → useReactionAggregation (반응 집계) + → useReactionMotion (반응 모션) + → usePartyroomNotice (공지) + → usePartyroomClose (파티룸 종료) + → usePartyroomDeactivation (비활성화) + → usePlaybackCallback (재생 상태) + → useDjQueueCallback (DJ 큐 변동) +``` + +### 핵심 파일 + +- `src/entities/partyroom-client/lib/handle-subscription-event.ts` — 디스패처 +- `src/entities/partyroom-client/lib/subscription-callbacks/` — 11개 콜백 훅 + +## 결과 + +- **(+)** 훅 단위 테스트 용이 — 11개 콜백 전부 유닛 테스트 완료 +- **(+)** 새 이벤트 타입 추가 시 훅 하나만 추가 +- **(+)** 각 콜백이 자신이 업데이트할 스토어 슬라이스만 알면 됨 +- **(-)** 파일 수가 많아짐 (11개 콜백 + 1개 디스패처) diff --git a/docs/adr/003-ui-barrel-export-split.md b/docs/adr/003-ui-barrel-export-split.md new file mode 100644 index 00000000..2bf1076a --- /dev/null +++ b/docs/adr/003-ui-barrel-export-split.md @@ -0,0 +1,42 @@ +# ADR-003: FSD barrel export를 `index.ts` / `index.ui.ts`로 분리 + +- **상태**: 채택됨 +- **일자**: 2024-10 + +## 맥락 + +Next.js App Router에서 서버 컴포넌트와 클라이언트 컴포넌트가 공존한다. FSD 모듈의 `index.ts`에 순수 함수와 React 컴포넌트(`'use client'`)를 함께 export하면, 서버 사이드에서 import할 때 클라이언트 코드가 번들에 끌려 들어온다. + +```tsx +// 이 import는 서버에서 Crew.Permission만 필요하지만, +// index.ts에 있는 useOpenGradeAdjustmentAlertDialog('use client')도 포함됨 +import { Crew } from '@/entities/current-partyroom'; +``` + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ------------------------------------ | ------------------------- | ------------------------------------ | +| 모든 export를 `index.ts` 하나로 | 단순 | SSR에서 클라이언트 코드 혼입 | +| **`index.ts` + `index.ui.ts` 분리** | 서버/클라이언트 경계 명확 | import 컨벤션 학습 필요 | +| barrel export 포기, 직접 경로 import | 혼입 없음 | 모듈 경계 무력화, import 경로 산발적 | + +## 결정 + +**`index.ts`(로직/모델)와 `index.ui.ts`(React 컴포넌트)를 분리**한다. + +- 서버 컴포넌트/순수 로직에서는 `@/entities/me` (index.ts) +- 클라이언트 컴포넌트에서는 `@/entities/me/index.ui` (index.ui.ts) + +### 적용된 모듈 + +- `src/entities/me/index.ts` + `src/entities/me/index.ui.ts` +- `src/entities/current-partyroom/index.ts` + `index.ui.ts` +- `src/shared/ui/components/dialog/index.ts` 등 + +## 결과 + +- **(+)** 서버/클라이언트 경계 명확 — 빌드 경고 제거 +- **(+)** tree-shaking에 유리 +- **(-)** import 경로 컨벤션(`index.ui`)을 팀원이 숙지해야 함 +- **(-)** 새 모듈 생성 시 두 파일을 관리해야 하는 오버헤드 diff --git a/docs/adr/004-msw-integration-testing.md b/docs/adr/004-msw-integration-testing.md new file mode 100644 index 00000000..8830a5fc --- /dev/null +++ b/docs/adr/004-msw-integration-testing.md @@ -0,0 +1,51 @@ +# ADR-004: 통합 테스트에 MSW v2 + Custom Jest Environment 채택 + +- **상태**: 채택됨 +- **일자**: 2026-02 + +## 맥락 + +API 통합 테스트에서 axios 호출 → 인터셉터(unwrapResponse) → React Query 캐시 갱신까지 전체 파이프라인을 검증해야 한다. `jest.mock('axios')`로는 인터셉터가 동작하지 않아 실제 코드 경로를 검증할 수 없다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ------------------------ | -------------------------------------- | ------------------------------- | +| `jest.mock('axios')` | 간단, 빠름 | 인터셉터 우회, 실제 경로 미검증 | +| nock | Node HTTP 가로채기 | axios 어댑터와 호환 이슈 | +| **MSW v2 `setupServer`** | 네트워크 레벨 가로채기, 실제 경로 검증 | SWC import 제거 문제 | + +## 결정 + +**MSW v2의 `setupServer`**를 채택한다. + +단, SWC의 CommonJS 변환이 사용하지 않는 named import를 제거하는 문제가 있다: + +```ts +// SWC가 server를 미사용으로 판단하여 import 자체를 제거함 +import { server } from '@/shared/api/__test__/msw-server'; +``` + +이를 해결하기 위해 **커스텀 Jest 환경**(`jest-msw-env.ts`)을 만들어 서버 lifecycle을 환경 레벨에서 관리한다. 테스트 파일에서는 bare import만 사용한다: + +```ts +/** + * @jest-environment /src/shared/api/__test__/jest-msw-env.ts + */ +import '@/shared/api/__test__/msw-server'; // side-effect import (SWC가 제거 불가) +``` + +### 핵심 파일 + +- `src/shared/api/__test__/jest-msw-env.ts` — 커스텀 Jest 환경 +- `src/shared/api/__test__/msw-server.ts` — MSW 서버 설정 +- `src/shared/api/__test__/handlers.ts` — 25+ 엔드포인트 핸들러 +- `src/shared/api/__test__/test-utils.tsx` — renderWithClient 헬퍼 + +## 결과 + +- **(+)** axios → 인터셉터 → unwrap → React Query 전체 경로 검증 +- **(+)** 핸들러 재사용으로 25+ 엔드포인트를 일관되게 모킹 +- **(+)** `server.use()`로 테스트별 응답 오버라이드 가능 +- **(-)** 커스텀 Jest 환경이라는 비표준 설정 — 새 팀원에게 설명 필요 +- **(-)** 통합 테스트 파일마다 `@jest-environment` 주석 필요 diff --git a/docs/adr/005-error-handling-strategy.md b/docs/adr/005-error-handling-strategy.md new file mode 100644 index 00000000..516efc30 --- /dev/null +++ b/docs/adr/005-error-handling-strategy.md @@ -0,0 +1,61 @@ +# ADR-005: 3계층 에러 처리 전략 (EventEmitter + Decorator + Global Handler) + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +API 호출 실패 시 다양한 수준의 에러 처리가 필요하다: + +1. **전역**: 인증 만료 시 로그인 페이지로 리다이렉트 +2. **전역 UI**: 알 수 없는 에러 시 에러 다이얼로그 표시 +3. **지역**: 특정 에러 코드에 대해 해당 컴포넌트가 직접 처리 (예: 파티룸 입장 실패 시 알림) + +단순 try-catch나 React Query의 onError만으로는 전역/지역 처리를 분리하기 어렵고, 특정 에러만 전역 핸들링을 건너뛰는 조건부 로직이 산재한다. + +## 결정 + +**3계층 에러 처리 시스템**을 구축한다. + +### 계층 1: ErrorEmitter (이벤트 브로드캐스트) + +axios 응답 인터셉터에서 에러 코드를 추출하여 `errorEmitter`로 브로드캐스트한다. 관심 있는 컴포넌트가 `useOnError` 훅으로 구독한다. + +``` +API 에러 발생 → response interceptor → errorEmitter.emit(errorCode) + ↓ + useOnError(ErrorCode.NOT_FOUND_ROOM, showAlert) +``` + +### 계층 2: @SkipGlobalErrorHandling (데코레이터) + +서비스 메서드에 데코레이터를 적용하여 특정 조건에서 전역 에러 다이얼로그를 억제한다. + +```ts +// 파티룸 입장 시 — 특정 에러 코드는 지역 핸들러가 처리 +@SkipGlobalErrorHandling>({ + when: (err) => [NOT_FOUND_ROOM, ALREADY_TERMINATED, EXCEEDED_LIMIT] + .includes(err.response?.data?.errorCode) +}) +async enter(partyroomId: number) { ... } +``` + +### 계층 3: React Query Global Error Handler + +`QueryCache`/`MutationCache`의 `onError`에서 `shouldSkipGlobalErrorHandling` 플래그를 확인한 뒤, 건너뛰기 대상이 아닌 에러만 전역 에러 다이얼로그로 표시한다. + +### 핵심 파일 + +- `src/shared/api/http/error/error-emitter.ts` — 싱글턴 EventEmitter +- `src/shared/api/http/error/use-on-error.hook.ts` — 지역 에러 구독 훅 +- `src/shared/api/http/client/interceptors/response.ts` — 인터셉터에서 emit +- `src/shared/lib/decorators/skip-global-error-handling/` — 데코레이터 +- `src/app/_providers/react-query.provider.tsx` — 전역 에러 핸들러 + +## 결과 + +- **(+)** 전역/지역 에러 처리가 명확히 분리 +- **(+)** 데코레이터 한 줄로 전역 핸들링 억제 — 비즈니스 로직과 에러 정책 분리 +- **(+)** 컴포넌트가 자신이 관심 있는 에러 코드만 구독 +- **(-)** 에러 흐름을 이해하려면 3계층 구조를 모두 파악해야 함 +- **(-)** 데코레이터가 에러 객체에 비열거형 프로퍼티를 주입하는 비표준 패턴 diff --git a/docs/adr/006-custom-i18n.md b/docs/adr/006-custom-i18n.md new file mode 100644 index 00000000..44d42a42 --- /dev/null +++ b/docs/adr/006-custom-i18n.md @@ -0,0 +1,57 @@ +# ADR-006: next-intl/i18next 대신 커스텀 i18n 구현 + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +다국어 지원(한국어/영어)이 필요하다. Next.js App Router 환경에서 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 번역 텍스트를 사용해야 하며, 번역 문자열 내에 **볼드 처리, 줄바꿈, 변수 치환** 같은 서식이 포함된다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| --------------- | -------------------------------------- | -------------------------------- | +| next-intl | App Router 네이티브 지원 | 2024 당시 App Router 지원 불안정 | +| react-i18next | 생태계 풍부 | SSR 설정 복잡, 번들 크기 큼 | +| **커스텀 구현** | 경량, 완전한 제어, 프로세서 파이프라인 | 유지보수 부담, 생태계 부재 | + +## 결정 + +**커스텀 i18n 시스템**을 구현한다. + +### 아키텍처 + +``` +서버: getServerDictionary() → JSON 동적 import → I18nProvider에 주입 + ↓ +클라이언트: useI18n() → t.partyroom.ec.shut_down (타입 안전) + ↓ + + ↓ + 프로세서 파이프라인 → **bold** → , \n →
, {{name}} → value +``` + +### 프로세서 파이프라인 + +`Trans` 컴포넌트가 `I18nProcessor` 인터페이스를 구현한 프로세서 배열을 받아 순차 적용한다: + +- `LineBreakProcessor` — `\n` → `
` +- `BoldProcessor` — `**텍스트**` → `텍스트` +- `VariableProcessor` — `{{name}}` → 실제 값 치환 + +### 핵심 파일 + +- `src/shared/lib/localization/get-server-dictionary.ts` — 서버 사이드 딕셔너리 로딩 +- `src/shared/lib/localization/i18n.context.tsx` — Context + useI18n 훅 +- `src/shared/lib/localization/lang.context.tsx` — 언어 선택 Context +- `src/shared/lib/localization/renderer/trans.component.tsx` — Trans 컴포넌트 +- `src/shared/lib/localization/renderer/processors/` — 프로세서 구현체 + +## 결과 + +- **(+)** 타입 안전한 키 접근 — `Leaves` 타입으로 오타 방지 +- **(+)** 프로세서 파이프라인으로 서식 처리 확장 용이 +- **(+)** 번들 크기 최소 — 외부 라이브러리 의존 없음 +- **(+)** 서버/클라이언트 양쪽에서 동일하게 동작 +- **(-)** 복수형(pluralization), 날짜/숫자 포맷 등 고급 기능 미지원 +- **(-)** 외부 번역 서비스(Crowdin, Phrase) 연동 시 추가 작업 필요 diff --git a/docs/adr/007-service-class-decorators.md b/docs/adr/007-service-class-decorators.md new file mode 100644 index 00000000..1659f206 --- /dev/null +++ b/docs/adr/007-service-class-decorators.md @@ -0,0 +1,69 @@ +# ADR-007: API 서비스 계층에 클래스 + 데코레이터 패턴 채택 + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +API 서비스 계층에서 다음 횡단 관심사(cross-cutting concerns)를 처리해야 한다: + +1. **싱글턴 보장** — 서비스 인스턴스가 하나만 존재 +2. **에러 핸들링 정책** — 특정 메서드만 전역 에러 처리를 건너뜀 +3. **개발용 모킹** — 백엔드 미구현 API를 프론트 단독으로 테스트 + +함수형 모듈로 구현하면 이런 횡단 관심사를 메서드별로 적용하기 어렵다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ----------------------- | ---------------------------- | --------------------------------- | +| 함수형 모듈 + HOF | React 생태계와 일관 | 메서드별 정책 적용 번거로움 | +| **클래스 + 데코레이터** | AOP 스타일, 선언적 정책 적용 | TS 데코레이터 실험적, 클래스 패턴 | + +## 결정 + +**TypeScript 클래스 + 데코레이터 패턴**을 채택한다. + +### 3종 데코레이터 + +```ts +@Singleton // 클래스 데코레이터 — 인스턴스 하나만 생성 +class PlaylistsService { + + @MockResolve({ data: mockData }) // 메서드 데코레이터 — 개발 모킹 + async getPlaylists() { ... } + + @SkipGlobalErrorHandling({ // 메서드 데코레이터 — 에러 정책 + when: (err) => err.response?.data?.errorCode === 'PLL-002' + }) + async createPlaylist() { ... } +} +``` + +| 데코레이터 | 레벨 | 역할 | +| ------------------------------ | ------ | ----------------------------------- | +| `@Singleton` | 클래스 | new 호출 시 기존 인스턴스 반환 | +| `@SkipGlobalErrorHandling` | 메서드 | 조건부 전역 에러 처리 억제 | +| `@MockResolve` / `@MockReturn` | 메서드 | `USE_MOCK=true`일 때 가짜 응답 반환 | + +### 적용 대상 + +- `src/shared/api/http/services/users.ts` +- `src/shared/api/http/services/partyrooms.ts` +- `src/shared/api/http/services/playlists.ts` +- `src/shared/api/http/services/crews.ts` +- `src/shared/api/http/services/djs.ts` + +### 핵심 파일 + +- `src/shared/lib/decorators/singleton/` — @Singleton +- `src/shared/lib/decorators/skip-global-error-handling/` — @SkipGlobalErrorHandling +- `src/shared/lib/decorators/mock/` — @MockResolve, @MockReturn + +## 결과 + +- **(+)** 횡단 관심사가 비즈니스 로직과 분리 — 메서드 본문은 순수 API 호출만 +- **(+)** 선언적 — 데코레이터 한 줄로 정책 적용 +- **(+)** 3종 데코레이터 모두 유닛 테스트 완료 +- **(-)** TypeScript `experimentalDecorators` 의존 — TC39 표준과 다소 차이 +- **(-)** 프론트엔드에서 클래스 패턴이 비주류 — 팀 온보딩 시 설명 필요 diff --git a/docs/adr/008-blockchain-wallet-stack.md b/docs/adr/008-blockchain-wallet-stack.md new file mode 100644 index 00000000..4e443ae4 --- /dev/null +++ b/docs/adr/008-blockchain-wallet-stack.md @@ -0,0 +1,63 @@ +# ADR-008: 블록체인 지갑 연동에 wagmi + viem + RainbowKit 채택 + +- **상태**: 채택됨 +- **일자**: 2024-06 + +## 맥락 + +PFPlay는 NFT 기반 아바타를 지원하며, 사용자가 지갑을 연결하여 보유 NFT를 조회하고 아바타로 사용할 수 있어야 한다. 지갑 연결 UI, 체인 전환, 트랜잭션 서명 등의 Web3 기능이 필요하다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ----------------------------- | ------------------------------------ | -------------------------------- | +| ethers.js 직접 사용 | 유연 | 지갑 UI/상태 관리 직접 구현 필요 | +| web3-react | Uniswap 검증 | 유지보수 불활발 | +| **wagmi + viem + RainbowKit** | React 네이티브, 타입 안전, UX 완성도 | 3개 라이브러리 조합 | + +## 결정 + +**wagmi + viem + RainbowKit** 조합을 채택한다. + +- **wagmi**: React hooks for Ethereum — `useAccount`, `useConnect` 등 +- **viem**: 경량 EVM 클라이언트 — ethers.js 대체 +- **RainbowKit**: 지갑 연결 모달 UI — 컴팩트 모드 + 다크 테마 + +NFT 데이터 조회에는 **Alchemy SDK**를 추가 사용한다. + +### 지원 체인 + +```ts +chains: [polygon, optimism, arbitrum]; +// mainnet은 viem issue #881로 인해 주석 처리 +``` + +### 하이드레이션 불일치 방지 + +```tsx +// wallet.provider.tsx +const [mounted, setMounted] = useState(false); +useEffect(() => setMounted(true), []); +if (!mounted) return null; +``` + +서버에서는 지갑 상태를 알 수 없으므로, 클라이언트 마운트 후에만 Provider를 렌더링한다. + +### 서버 동기화 + +`useGlobalWalletSync` 훅이 지갑 연결/해제 시 백엔드에 주소를 동기화한다. + +### 핵심 파일 + +- `src/app/_providers/wallet.provider.tsx` — Provider 설정 +- `src/entities/wallet/` — 지갑 관련 엔티티 (훅, 모델, API) +- `src/entities/wallet/lib/use-global-wallet-sync.hook.ts` — 서버 동기화 + +## 결과 + +- **(+)** React 훅 기반으로 선언적 지갑 상태 관리 +- **(+)** RainbowKit 모달 UI로 지갑 연결 UX 완성도 높음 +- **(+)** viem의 타입 안전성 — 체인 ID, ABI 인코딩 등 컴파일 타임 검증 +- **(-)** wagmi + viem + RainbowKit 3개 라이브러리 의존 +- **(-)** Alchemy SDK는 MSW로 테스트 불가 — 별도 인프라 필요 +- **(-)** viem 호환 이슈로 mainnet 비활성화 상태 diff --git a/docs/adr/009-error-monitoring.md b/docs/adr/009-error-monitoring.md new file mode 100644 index 00000000..a83eed57 --- /dev/null +++ b/docs/adr/009-error-monitoring.md @@ -0,0 +1,40 @@ +# ADR-009: 프로덕션 에러 모니터링 전략 + +- **상태**: 미결정 +- **일자**: 2026-03-03 + +## 맥락 + +현재 프로덕션 에러는 `console.error`로만 출력되며, 외부 모니터링 서비스가 전혀 연동되어 있지 않다. 유저가 겪는 에러를 개발팀이 알 방법이 없고, 에러 빈도·추세·영향 범위를 분석할 수 없다. + +에러가 발생하는 지점은 최소 6곳이다: + +| 위치 | 파일 | 현재 처리 | +| ----------------------- | -------------------------------------- | --------------- | +| React Query 전역 핸들러 | `react-query.provider.tsx:78,89` | `console.error` | +| YouTube 플레이어 | `youtube-preview-player.component.tsx` | `console.error` | +| 아바타 업데이트 | `done.component.tsx` | `console.error` | +| OAuth 토큰 교환 | `use-callback-login.ts` | `console.error` | +| OAuth 로그인 시작 | `use-initiate-login.ts` | `console.error` | +| WebSocket 연결 실패 | `client.ts` | 로그 없음 | + +## 선택지 + +| 선택지 | 장점 | 단점 | 비용 | +| ----------- | ----------------------------------------------------- | -------------------------- | ----------------- | +| **Sentry** | 프론트엔드 에러 추적 특화, 소스맵 지원, 세션 리플레이 | 이벤트 수 기반 과금 | 무료 5K 이벤트/월 | +| LogRocket | 세션 리플레이 강점 | 에러 분석보다 UX 분석 위주 | 무료 1K 세션/월 | +| Datadog RUM | APM 통합, 백엔드 연계 | 프론트 단독 사용 시 과잉 | 유료 | +| 자체 구축 | 완전한 제어 | 개발·유지보수 비용 과다 | 인력 비용 | + +## 결정 + +> 미결정 — 선택지 검토 후 결정 필요 + +## 도입 시 고려사항 + +- Next.js App Router와의 호환성 (서버/클라이언트 양쪽 에러 캡처) +- 기존 `errorEmitter` 패턴과의 통합 지점 +- 소스맵 업로드 자동화 (빌드 파이프라인 연동) +- PII(개인식별정보) 필터링 — 지갑 주소, 닉네임 등 +- 에러 샘플링 비율 설정 (비용 제어) diff --git a/docs/adr/010-accessibility-strategy.md b/docs/adr/010-accessibility-strategy.md new file mode 100644 index 00000000..f327713a --- /dev/null +++ b/docs/adr/010-accessibility-strategy.md @@ -0,0 +1,34 @@ +# ADR-010: 접근성(a11y) 전략 수립 + +- **상태**: 미결정 +- **일자**: 2026-03-03 + +## 맥락 + +현재 코드베이스의 접근성 지원이 최소 수준이다: + +- `aria-label` 4개, `role` 9개가 전부 +- 인터랙티브 `div`에 `tabIndex`/키보드 핸들러 없음 +- `data-custom-role` 같은 비표준 속성 사용 +- WCAG 2.1 Level A 기준 약 40% 수준 추정 + +PFPlay는 음악 재생·채팅·아바타 등 인터랙션이 많은 서비스로, 키보드/스크린 리더 사용자에 대한 지원이 부족하면 사용 자체가 불가능한 영역이 있다. + +## 선택지 + +| 선택지 | 목표 수준 | 작업량 | 비고 | +| -------------------- | ----------- | ------ | --------------------------- | +| 현행 유지 | - | 없음 | 법적 리스크 감수 | +| **WCAG 2.1 Level A** | 최소 접근성 | 중간 | 키보드 탐색, 기본 ARIA | +| WCAG 2.1 Level AA | 표준 접근성 | 높음 | 색상 대비, 포커스 관리 포함 | + +## 결정 + +> 미결정 — 목표 수준과 적용 범위 결정 필요 + +## 도입 시 고려사항 + +- CI에 `axe-core` 또는 `jest-axe` 통합하여 자동 검사 +- Headless UI 컴포넌트는 이미 ARIA 패턴을 내장 — 커스텀 컴포넌트가 주요 개선 대상 +- 다크 모드 전용 UI에서 색상 대비 검증 필요 +- 채팅·실시간 알림 영역의 `aria-live` region 적용 diff --git a/docs/adr/011-fsd-import-boundary.md b/docs/adr/011-fsd-import-boundary.md new file mode 100644 index 00000000..4f1670b7 --- /dev/null +++ b/docs/adr/011-fsd-import-boundary.md @@ -0,0 +1,41 @@ +# ADR-011: FSD 레이어 간 import 경계 강제 방안 + +- **상태**: 미결정 +- **일자**: 2026-03-03 + +## 맥락 + +FSD 아키텍처의 핵심 규칙은 **상위 레이어만 하위 레이어를 import할 수 있다**는 것이다: + +``` +app → widgets → features → entities → shared (허용) +shared → entities (위반) +features → features (위반) +entities → features (위반) +``` + +현재 ESLint 커스텀 룰 2개가 있지만, 레이어 간 의존 방향은 검증하지 않는다: + +- `no-direct-service-method-reference` — React Query 내 서비스 호출 패턴 +- `no-absolute-import-without-prefix` — `@/` 접두사 강제 + +프로젝트 규모가 커지면 의존 방향 위반이 누적되어 아키텍처가 무력화된다. + +## 선택지 + +| 선택지 | 장점 | 단점 | +| ---------------------------------------- | ----------------------- | ------------------------------------------ | +| **eslint-plugin-boundaries** | FSD 전용 설정 예시 풍부 | 외부 의존성 추가 | +| ESLint `no-restricted-imports` 수동 설정 | 의존성 없음 | 레이어 조합별 수동 나열, 유지보수 번거로움 | +| 자체 ESLint 플러그인 확장 | 기존 커스텀 룰과 일관 | 개발 비용 | +| Dependency Cruiser | 시각화 + 검증 | CI 통합 별도, 실시간 피드백 없음 | + +## 결정 + +> 미결정 — 도구 선정 및 규칙 범위 결정 필요 + +## 도입 시 고려사항 + +- 기존 위반 사례가 있을 수 있으므로, 먼저 `warn`으로 도입 후 점진적으로 `error`로 전환 +- `index.ui.ts` 분리 패턴(ADR-003)과의 정합성 확인 +- 같은 레이어 내 cross-slice import 허용 범위 결정 (예: `entities/me` → `entities/wallet` 허용 여부) diff --git a/eslint.config.js b/eslint.config.js index a0e6873b..0c23fd64 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,6 @@ const eslint = require('@eslint/js'); const pluginNext = require('@next/eslint-plugin-next'); const pluginI18next = require('eslint-plugin-i18next'); const pluginImport = require('eslint-plugin-import'); -const pluginJest = require('eslint-plugin-jest'); const pluginReactHooks = require('eslint-plugin-react-hooks'); const pluginStorybook = require('eslint-plugin-storybook'); const unusedImportsPlugin = require('eslint-plugin-unused-imports'); @@ -134,14 +133,7 @@ module.exports = tseslint.config( }, { files: ['**/*.test.*'], - plugins: { - jest: pluginJest, - }, - languageOptions: { - globals: pluginJest.environments.globals.globals, - }, rules: { - 'jest/no-identical-title': 0, 'i18next/no-literal-string': 0, }, } diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index cca577b3..00000000 --- a/jest.config.js +++ /dev/null @@ -1,20 +0,0 @@ -const fs = require('fs'); -const { defaults } = require('jest-config'); - -const swcConfig = JSON.parse(fs.readFileSync(`${__dirname}/.swcrc`, 'utf-8')); - -module.exports = { - ...defaults, - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.test.json', - }, - }, - setupFilesAfterEnv: ['/jest.setup.js'], - transform: { '^.+\\.(ts|tsx|js|jsx)$': ['@swc/jest', { ...swcConfig }] }, - moduleDirectories: ['node_modules', '/src'], - testEnvironment: 'jsdom', - moduleNameMapper: { - '@/(.*)': '/src/$1', - }, -}; diff --git a/jest.setup.js b/jest.setup.js deleted file mode 100644 index d9d93a3f..00000000 --- a/jest.setup.js +++ /dev/null @@ -1,4 +0,0 @@ -import 'jest-plugin-context/setup'; -import 'given2/setup'; - -global.console.warn = () => {}; diff --git a/next.config.js b/next.config.js index 369330a9..4b6720ac 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,7 @@ const nextConfig = { swcMinify: true, experimental: { webpackBuildWorker: true, + serverComponentsExternalPackages: ['@resvg/resvg-js'], }, webpack: (config) => { config.resolve.fallback = { fs: false, net: false, tls: false }; diff --git a/package-lock.json b/package-lock.json index cbfb7306..7f52e5ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@headlessui/react": "^2.1.2", "@hookform/resolvers": "^2.9.10", "@rainbow-me/rainbowkit": "2.2.1", + "@resvg/resvg-js": "^2.6.2", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.45.1", "@tanstack/react-query-next-experimental": "^5.46.0", @@ -25,6 +26,7 @@ "html-parse-stringify": "^3.0.1", "html2canvas": "^1.4.1", "lottie-react": "^2.4.0", + "moveable-helper": "^0.4.0", "next": "14.2.15", "pm2": "^5.4.1", "postcss-nesting": "^12.0.1", @@ -33,8 +35,10 @@ "react-error-boundary": "^4.0.11", "react-fast-marquee": "^1.6.4", "react-hook-form": "^7.41.5", + "react-moveable": "^0.56.0", "react-player": "^2.16.0", "react-share": "^5.1.0", + "satori": "^0.25.0", "tailwind-merge": "^1.13.2", "tailwind-scrollbar-hide": "^1.1.7", "typescript": "^5.5.4", @@ -94,6 +98,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-plugin-context": "^2.9.0", "lint-staged": "^13.2.3", + "msw": "^2.12.10", "postcss": "^8.4.18", "prettier": "^3.4.2", "storybook": "^7.5.2", @@ -2398,6 +2403,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@cfcs/core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.1.0.tgz", + "integrity": "sha512-kvYX0RpL45XTHJ5sW7teNbKeAa7pK3nNqaJPoFfZDPTIBJOkTtRD3QhkBG+O3Hu69a8xeMoPvF6y/RtJ6JUOdA==", + "license": "MIT", + "dependencies": { + "@egjs/component": "^3.0.4" + } + }, "node_modules/@coinbase/wallet-sdk": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-4.0.4.tgz", @@ -2467,6 +2481,12 @@ "postcss-selector-parser": "^6.0.13" } }, + "node_modules/@daybrush/utils": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@daybrush/utils/-/utils-1.13.0.tgz", + "integrity": "sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==", + "license": "MIT" + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -2530,6 +2550,33 @@ "react": ">=16.8.0" } }, + "node_modules/@egjs/agent": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@egjs/agent/-/agent-2.4.4.tgz", + "integrity": "sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==", + "license": "MIT" + }, + "node_modules/@egjs/children-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz", + "integrity": "sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==", + "license": "MIT", + "dependencies": { + "@egjs/list-differ": "^1.0.0" + } + }, + "node_modules/@egjs/component": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.5.tgz", + "integrity": "sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==", + "license": "MIT" + }, + "node_modules/@egjs/list-differ": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@egjs/list-differ/-/list-differ-1.0.1.tgz", + "integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==", + "license": "MIT" + }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -3685,6 +3732,132 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4769,6 +4942,24 @@ "tslib": "^2.3.1" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@ndelangen/get-tarball": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", @@ -4830,6 +5021,126 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", + "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", + "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz", + "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", + "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", + "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", + "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", + "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", + "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/curves": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", @@ -4917,6 +5228,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz", @@ -6980,6 +7316,221 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -7194,6 +7745,34 @@ "node": ">=16" } }, + "node_modules/@scena/dragscroll": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scena/dragscroll/-/dragscroll-1.4.0.tgz", + "integrity": "sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.6.0", + "@scena/event-emitter": "^1.0.2" + } + }, + "node_modules/@scena/event-emitter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@scena/event-emitter/-/event-emitter-1.0.5.tgz", + "integrity": "sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.1.1" + } + }, + "node_modules/@scena/matrix": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scena/matrix/-/matrix-1.1.1.tgz", + "integrity": "sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.4.0" + } + }, "node_modules/@scure/base": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.7.tgz", @@ -7272,6 +7851,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -11074,6 +11669,13 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -13949,6 +14551,15 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001651", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", @@ -14406,6 +15017,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -15285,6 +15906,36 @@ "node": ">=8" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -15360,6 +16011,36 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-styled": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/css-styled/-/css-styled-1.0.8.tgz", + "integrity": "sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0" + } + }, + "node_modules/css-to-mat": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/css-to-mat/-/css-to-mat-1.1.1.tgz", + "integrity": "sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/matrix": "^1.0.0" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -16423,6 +17104,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -18293,6 +18983,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -18609,6 +19305,12 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framework-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/framework-utils/-/framework-utils-1.1.0.tgz", + "integrity": "sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==", + "license": "MIT" + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -18773,6 +19475,16 @@ "node": ">=6.9.0" } }, + "node_modules/gesto": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/gesto/-/gesto-1.19.4.tgz", + "integrity": "sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@scena/event-emitter": "^1.0.2" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -19120,6 +19832,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -19318,6 +20040,13 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.28.1.tgz", @@ -19335,6 +20064,18 @@ "hermes-estree": "0.28.1" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hey-listen": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", @@ -20230,6 +20971,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -22038,6 +22786,33 @@ "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", "license": "MIT" }, + "node_modules/keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==", + "license": "MIT" + }, + "node_modules/keycon": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/keycon/-/keycon-1.4.0.tgz", + "integrity": "sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==", + "license": "MIT", + "dependencies": { + "@cfcs/core": "^0.0.6", + "@daybrush/utils": "^1.7.1", + "@scena/event-emitter": "^1.0.2", + "keycode": "^2.2.0" + } + }, + "node_modules/keycon/node_modules/@cfcs/core": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.6.tgz", + "integrity": "sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==", + "license": "MIT", + "dependencies": { + "@egjs/component": "^3.0.2" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -22252,6 +23027,25 @@ "node": ">=10" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -24026,20 +24820,210 @@ "@motionone/vue": "^10.16.2" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "node_modules/moveable-helper": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/moveable-helper/-/moveable-helper-0.4.0.tgz", + "integrity": "sha512-t1FK9PO187Gn0N6GVZcrQgePjiHmuj8eUhmJjH38LvTMnVVxiHzWYRx6ARFZvSFIIW4yb6BEAv4C99Bsx84nFw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.0.0", + "scenejs": "^1.4.2" + }, + "peerDependencies": { + "scenejs": ">=1.4.1" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/msw/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/msw/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/msw/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/multiformats": { "version": "9.9.0", @@ -25038,6 +26022,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/order-map": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/order-map/-/order-map-0.3.1.tgz", + "integrity": "sha512-RSuElIGwzPuBLzS9Io7G8fpcnQeudg0XswOyOiwRNLX7lkf+eQ/KUp+kcAP7z7nTOdkrfxhZycyXwzFW75iJ6A==", + "license": "MIT" + }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -25045,6 +26035,22 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/overlap-area": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz", + "integrity": "sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.7.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -25269,6 +26275,16 @@ "node": ">= 0.10" } }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -26216,7 +27232,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/preact": { @@ -26856,6 +27871,16 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-css-styled": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-css-styled/-/react-css-styled-1.1.9.tgz", + "integrity": "sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==", + "license": "MIT", + "dependencies": { + "css-styled": "~1.0.8", + "framework-utils": "^1.1.0" + } + }, "node_modules/react-devtools-core": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", @@ -27071,6 +28096,27 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-moveable": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/react-moveable/-/react-moveable-0.56.0.tgz", + "integrity": "sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/agent": "^2.2.1", + "@egjs/children-differ": "^1.0.1", + "@egjs/list-differ": "^1.0.0", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "@scena/matrix": "^1.1.1", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.3", + "overlap-area": "^1.1.0", + "react-css-styled": "^1.1.9", + "react-selecto": "^1.25.0" + } + }, "node_modules/react-native": { "version": "0.80.1", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.80.1.tgz", @@ -27390,6 +28436,15 @@ } } }, + "node_modules/react-selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/react-selecto/-/react-selecto-1.26.3.tgz", + "integrity": "sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==", + "license": "MIT", + "dependencies": { + "selecto": "~1.26.3" + } + }, "node_modules/react-share": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.1.0.tgz", @@ -28002,6 +29057,13 @@ "node": ">=8" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -28355,6 +29417,28 @@ } } }, + "node_modules/satori": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.25.0.tgz", + "integrity": "sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw==", + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -28374,6 +29458,19 @@ "node": ">=10" } }, + "node_modules/scenejs": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/scenejs/-/scenejs-1.10.3.tgz", + "integrity": "sha512-o1Xrz5sRMeVOD5R9MizY2tYVPYeRnQNttiNRD7vtfi4j4+su1nuP2R/1yv3jDNol1zFfFHwwh2G0jxyt0SIqUA==", + "license": "MIT", + "dependencies": { + "@cfcs/core": "^0.1.0", + "@daybrush/utils": "^1.10.2", + "@scena/event-emitter": "^1.0.3", + "css-styled": "^1.0.6", + "order-map": "^0.3.1" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -28429,6 +29526,24 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/selecto": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/selecto/-/selecto-1.26.3.tgz", + "integrity": "sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==", + "license": "MIT", + "dependencies": { + "@daybrush/utils": "^1.13.0", + "@egjs/children-differ": "^1.0.1", + "@scena/dragscroll": "^1.4.0", + "@scena/event-emitter": "^1.0.5", + "css-styled": "^1.0.8", + "css-to-mat": "^1.1.1", + "framework-utils": "^1.1.0", + "gesto": "^1.19.4", + "keycon": "^1.2.0", + "overlap-area": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -29307,6 +30422,13 @@ "bare-events": "^2.2.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -29379,6 +30501,12 @@ "node": ">=8" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -29747,6 +30875,19 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", @@ -30268,6 +31409,12 @@ "node": ">=0.6.0" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -30275,6 +31422,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -30838,6 +32005,16 @@ "node": ">=4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -31008,6 +32185,16 @@ } } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -32219,6 +33406,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", @@ -32322,126 +33528,6 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", - "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", - "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz", - "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", - "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", - "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", - "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", - "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", - "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index c535f38a..25d2b123 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "next build && ./scripts/setup-standalone.sh", "start": "next start", "pm2": "pm2 start yarn --interpreter bash -- start && pm2 monit", - "test": "jest src eslint-custom-plugin", + "test": "vitest run", "test:type": "tsc --noEmit", "lint-staged": "lint-staged", "lint": "eslint src --fix --quiet", @@ -27,6 +27,7 @@ "@headlessui/react": "^2.1.2", "@hookform/resolvers": "^2.9.10", "@rainbow-me/rainbowkit": "2.2.1", + "@resvg/resvg-js": "^2.6.2", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.45.1", "@tanstack/react-query-next-experimental": "^5.46.0", @@ -50,6 +51,7 @@ "react-moveable": "^0.56.0", "react-player": "^2.16.0", "react-share": "^5.1.0", + "satori": "^0.25.0", "tailwind-merge": "^1.13.2", "tailwind-scrollbar-hide": "^1.1.7", "typescript": "^5.5.4", @@ -75,18 +77,16 @@ "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-prettier": "^8.1.0", - "@swc/core": "^1.3.67", - "@swc/jest": "^0.2.26", + "@swc/core": "1.7.11", "@tanstack/react-query-devtools": "^5.17.1", "@testing-library/react": "^16.2.0", "@types/d3-force": "^3.0.10", - "@types/jest": "^29.5.12", - "@types/jest-plugin-context": "^2.9.5", "@types/node": "18.16.19", "@types/react": "^18.2.33", "@types/react-dom": "18.0.6", "@types/react-modal": "^3.13.1", "@typescript-eslint/rule-tester": "^8.20.0", + "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.4.12", "chokidar": "^3.5.3", "eslint": "^9.18.0", @@ -94,27 +94,25 @@ "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-i18next": "^6.1.1", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.11.0", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-storybook": "^0.11.2", "eslint-plugin-unused-imports": "^4.1.4", "exceljs": "^4.4.0", - "given2": "^2.1.7", "glob": "^10.3.10", "globals": "^15.14.0", "husky": "^8.0.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jest-plugin-context": "^2.9.0", + "jsdom": "^28.1.0", "lint-staged": "^13.2.3", + "msw": "^2.12.10", "postcss": "^8.4.18", "prettier": "^3.4.2", "storybook": "^7.5.2", "tailwindcss": "^3.2.1", "type-fest": "^4.20.1", "typescript-eslint": "^8.20.0", + "vitest": "^4.0.18", "webpack": "5.94.0" }, "engines": { diff --git a/public/fonts/NotoSansKR-Regular.otf b/public/fonts/NotoSansKR-Regular.otf new file mode 100644 index 00000000..dc4a7c65 Binary files /dev/null and b/public/fonts/NotoSansKR-Regular.otf differ diff --git a/src/app/(auth)/auth/callback/google/page.tsx b/src/app/(auth)/auth/callback/google/page.tsx index ebc1f292..1d2ffbbf 100644 --- a/src/app/(auth)/auth/callback/google/page.tsx +++ b/src/app/(auth)/auth/callback/google/page.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useSocialSignInCallback } from '@/features/sign-in/by-social'; export default function GoogleCallbackPage() { const handleCallback = useSocialSignInCallback(); + const called = useRef(false); useEffect(() => { + if (called.current) return; + called.current = true; handleCallback('google'); }, [handleCallback]); diff --git a/src/app/(auth)/auth/callback/twitter/page.tsx b/src/app/(auth)/auth/callback/twitter/page.tsx index 4ac9333d..6e5c3864 100644 --- a/src/app/(auth)/auth/callback/twitter/page.tsx +++ b/src/app/(auth)/auth/callback/twitter/page.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useSocialSignInCallback } from '@/features/sign-in/by-social'; export default function TwitterCallbackPage() { const handleCallback = useSocialSignInCallback(); + const called = useRef(false); useEffect(() => { + if (called.current) return; + called.current = true; handleCallback('twitter'); }, [handleCallback]); diff --git a/src/app/_providers/partyroom-connection.provider.tsx b/src/app/_providers/partyroom-connection.provider.tsx index d7777c62..b897ef57 100644 --- a/src/app/_providers/partyroom-connection.provider.tsx +++ b/src/app/_providers/partyroom-connection.provider.tsx @@ -1,24 +1,34 @@ 'use client'; -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useEffect, useRef, useState } from 'react'; import { useFetchMe } from '@/entities/me'; import { PartyroomClient, PartyroomClientContext } from '@/entities/partyroom-client'; export default function PartyroomConnectionProvider({ children }: { children: ReactNode }) { const { data: me } = useFetchMe(); - const [client] = useState(() => new PartyroomClient()); + const clientRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + if (!clientRef.current) { + clientRef.current = new PartyroomClient(); + } + setMounted(true); + }, []); useEffect(() => { /** * - 웹 페이지 진입 시 쿠키가 유효하다면 이 때도 바로 activate 되어야 함. - * - 즉, “로그인 시점“이 아닌, “인증이 유효하다” 판단 되는 시점에 activate 되어야 함. + * - 즉, “로그인 시점”이 아닌, “인증이 유효하다” 판단 되는 시점에 activate 되어야 함. * - connect는 라우트는 가리지 않음 라우트를 가리는건 파티룸 sub, unsub 뿐임 */ - if (me && !client.connected) { - client.connect(); + if (me && clientRef.current && !clientRef.current.connected) { + clientRef.current.connect(); } - }, [me]); + }, [me, mounted]); return ( - {children} + + {children} + ); } diff --git a/src/app/_providers/react-query.provider.tsx b/src/app/_providers/react-query.provider.tsx index 3e015183..394ffaa8 100644 --- a/src/app/_providers/react-query.provider.tsx +++ b/src/app/_providers/react-query.provider.tsx @@ -80,7 +80,7 @@ function handleBubbledError(error: unknown) { } if (isAuthError(error)) { - if (location.pathname !== '/') { + if (location.pathname !== '/' && !location.pathname.startsWith('/link/')) { location.href = '/'; } return; diff --git a/src/app/_providers/wallet.provider.tsx b/src/app/_providers/wallet.provider.tsx index eaec3311..2d1974ca 100644 --- a/src/app/_providers/wallet.provider.tsx +++ b/src/app/_providers/wallet.provider.tsx @@ -40,7 +40,7 @@ const wagmiConfig = getDefaultConfig({ optimism, arbitrum, ], - // ssr: true, + ssr: true, transports: { // [mainnet.id]: http(), [polygon.id]: http(), diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx new file mode 100644 index 00000000..69efd1ee --- /dev/null +++ b/src/app/api/og/route.tsx @@ -0,0 +1,177 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { NextRequest, NextResponse } from 'next/server'; +import type { ReactNode } from 'react'; +import { Resvg } from '@resvg/resvg-js'; +import satori from 'satori'; + +export const dynamic = 'force-dynamic'; + +const WIDTH = 1200; +const HEIGHT = 630; + +const API_BASE = process.env.NEXT_PUBLIC_API_HOST_NAME; + +type PartyroomOG = { + partyroomId: number; + title: string; + introduction: string; + crewCount: number; + playback: { + name: string; + thumbnailImage: string; + } | null; +}; + +async function fetchPartyroomByLink(linkDomain: string): Promise { + try { + const res = await fetch(`${API_BASE}v1/partyrooms/link/${linkDomain}`, { + cache: 'no-store', + }); + if (!res.ok) return null; + const json = await res.json(); + return json.data ?? json; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const linkDomain = searchParams.get('linkDomain'); + + const partyroom = linkDomain ? await fetchPartyroomByLink(linkDomain) : null; + + const logoPath = join(process.cwd(), 'public', 'images', 'Logo', 'wordmark_large_white.png'); + const logoData = await readFile(logoPath); + const logoBase64 = `data:image/png;base64,${logoData.toString('base64')}`; + + const fontPath = join(process.cwd(), 'public', 'fonts', 'NotoSansKR-Regular.otf'); + const fontData = (await readFile(fontPath)).buffer as ArrayBuffer; + + const hasPlayback = partyroom?.playback != null; + + const element: ReactNode = ( +
+ {/* 상단: 로고 */} +
+ +
+ + {/* 중앙: 썸네일 (전체 너비) */} +
+ {hasPlayback && partyroom?.playback ? ( + + ) : ( + + )} +
+ + {/* 하단: 제목 + 소개 + 참여자 수 */} +
+
+
+ {partyroom?.title ?? 'PFPlay Partyroom'} +
+
+ {partyroom?.introduction ?? 'PFP Playground for music'} +
+
+ + {partyroom && ( +
+ + + + {partyroom.crewCount} +
+ )} +
+
+ ); + + const svg = await satori(element, { + width: WIDTH, + height: HEIGHT, + fonts: [ + { + name: 'sans-serif', + data: fontData, + weight: 400, + style: 'normal', + }, + ], + }); + + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: WIDTH }, + }); + const pngData = resvg.render(); + const pngBuffer = pngData.asPng(); + + return new NextResponse(pngBuffer, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d16cdadf..dac17e19 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import '@rainbow-me/rainbowkit/styles.css'; import '@/shared/ui/foundation/globals.css'; import { PropsWithChildren } from 'react'; -import { MeHydration } from '@/entities/me'; + import { DomId } from '@/shared/config/dom-id'; import { Language } from '@/shared/lib/localization/constants'; import { LANGUAGE_COOKIE_KEY } from '@/shared/lib/localization/constants'; @@ -14,7 +14,7 @@ import { I18nProvider } from '@/shared/lib/localization/i18n.context'; import { LangProvider } from '@/shared/lib/localization/lang.context'; import { DialogProvider } from '@/shared/ui/components/dialog'; import { pretendardVariable } from '@/shared/ui/foundation/fonts'; -import PartyroomConnectionProvider from './_providers/partyroom-connection.provider'; + import ReactQueryProvider from './_providers/react-query.provider'; import StoresProvider from './_providers/stores.provider'; import { WalletProvider } from './_providers/wallet.provider'; @@ -39,11 +39,7 @@ const RootLayout = async ({ children }: PropsWithChildren) => { - - - {children} - - + {children} diff --git a/src/app/link/[linkDomain]/layout.tsx b/src/app/link/[linkDomain]/layout.tsx new file mode 100644 index 00000000..3473168f --- /dev/null +++ b/src/app/link/[linkDomain]/layout.tsx @@ -0,0 +1,45 @@ +import { Metadata } from 'next'; +import { PropsWithChildren } from 'react'; + +const API_BASE = process.env.NEXT_PUBLIC_API_HOST_NAME; + +type PartyroomOG = { + title: string; + introduction: string; +}; + +async function fetchPartyroomByLink(linkDomain: string): Promise { + try { + const res = await fetch(`${API_BASE}v1/partyrooms/link/${linkDomain}`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return null; + const json = await res.json(); + return json.data ?? json; + } catch { + return null; + } +} + +type Props = { + params: { linkDomain: string }; +}; + +export async function generateMetadata({ params }: Props): Promise { + const partyroom = await fetchPartyroomByLink(params.linkDomain); + + return { + title: partyroom?.title ?? 'PFPlay', + description: partyroom?.introduction ?? 'PFP Playground for music', + openGraph: { + title: partyroom?.title ?? 'PFPlay', + description: partyroom?.introduction ?? 'PFP Playground for music', + type: 'website', + images: [`/api/og?linkDomain=${params.linkDomain}`], + }, + }; +} + +export default function LinkLayout({ children }: PropsWithChildren) { + return <>{children}; +} diff --git a/src/app/link/[linkDomain]/page.tsx b/src/app/link/[linkDomain]/page.tsx index 4d02d26f..2def7c85 100644 --- a/src/app/link/[linkDomain]/page.tsx +++ b/src/app/link/[linkDomain]/page.tsx @@ -5,35 +5,37 @@ import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { partyroomsService } from '@/shared/api/http/services'; import { APIError } from '@/shared/api/http/types/@shared'; -import { EnterByLinkResponse } from '@/shared/api/http/types/partyrooms'; +import { GetPartyroomByLinkResponse } from '@/shared/api/http/types/partyrooms'; /** * SharedLinkPage 컴포넌트 * - * 이 컴포넌트는 공유 링크를 통해 파티룸에 입장할 때 사용됩니다. - * 주요 기능: - * 1. URL 파라미터로 받은 도메인을 사용하여 파티룸 입장을 시도합니다. - * 2. 에러 발생 시 Error 객체를 throw하여 Next.js의 error.tsx에서 처리하도록 합니다. - * 3. 파티룸 입장 처리 중에는 로딩 디자인을 렌더링합니다. - * + * 공유 링크를 통해 파티룸에 입장할 때 사용됩니다. + * 1. getPartyroomByLink로 파티룸 정보를 조회합니다. (GET, 부작용 없음) + * 2. 조회된 partyroomId로 파티룸 페이지로 이동합니다. + * 3. 게스트 인증은 파티룸 페이지의 useAutoSignIn에서 처리합니다. */ export default function LinkPage() { const { linkDomain } = useParams<{ linkDomain: string }>(); const router = useRouter(); - const { mutate: enterByLink } = useMutation, string>({ - mutationFn: (domain) => partyroomsService.enterByLink({ linkDomain: domain }), + const { mutate: getPartyroom } = useMutation< + GetPartyroomByLinkResponse, + AxiosError, + string + >({ + mutationFn: (domain) => partyroomsService.getPartyroomByLink({ linkDomain: domain }), }); useEffect(() => { if (linkDomain) { - enterByLink(linkDomain, { + getPartyroom(linkDomain, { onSuccess: ({ partyroomId }) => { router.push(`/parties/${partyroomId}`); }, }); } - }, [linkDomain, enterByLink, router]); + }, [linkDomain, getPartyroom, router]); // TODO: 로딩 디자인 적용 return
Loading...
; diff --git a/src/app/parties/layout.tsx b/src/app/parties/layout.tsx index e67470d2..b654fac4 100644 --- a/src/app/parties/layout.tsx +++ b/src/app/parties/layout.tsx @@ -10,6 +10,7 @@ import isAuthError from '@/shared/api/http/error/is-auth-error'; import { SidebarPlayer, ModalPlayer } from '@/widgets/music-preview-player'; import { MyPlaylist } from '@/widgets/my-playlist'; import PlaylistActionProvider from './playlist-action.provider'; +import PartyroomConnectionProvider from '../_providers/partyroom-connection.provider'; const ProtectedLayout = ({ children }: PropsWithChildren) => { const { data: me, error, isLoading } = useFetchMe(); @@ -40,21 +41,23 @@ const ProtectedLayout = ({ children }: PropsWithChildren) => { usePartyroomEnterErrorAlerts(); - if (isLoading || isSigningIn || !me) { + if (isLoading || isSigningIn || !me || !me.profileUpdated) { return null; } return ( - - {children} - + + + {children} + - {/* ⓐ 사이드바 미리보기 플레이어 (플레이리스트 트랙용) */} - + {/* ⓐ 사이드바 미리보기 플레이어 (플레이리스트 트랙용) */} + - {/* ⓑ 모달 미리보기 플레이어 (검색 결과용) - 모달과 분리된 고정 위치 */} - - + {/* ⓑ 모달 미리보기 플레이어 (검색 결과용) - 모달과 분리된 고정 위치 */} + + + ); }; diff --git a/src/entities/avatar/lib/calculate-dimensions.test.ts b/src/entities/avatar/lib/calculate-dimensions.test.ts index 4c165a9a..d4d464cf 100644 --- a/src/entities/avatar/lib/calculate-dimensions.test.ts +++ b/src/entities/avatar/lib/calculate-dimensions.test.ts @@ -57,4 +57,22 @@ describe('calculateDimensions', () => { offsetY: BASE_Y, }); }); + + test('절반 높이(80) → width=60', () => { + const result = calculateDimensions(80); + expect(result.width).toBe(60); + }); + + test('scale → zoom 반영', () => { + const result = calculateDimensions(BODY_BASE_HEIGHT, 0, 0, 0, 0, 2.5); + expect(result.zoom).toBe(2.5); + }); + + test('x, y offset 계산', () => { + const result = calculateDimensions(BODY_BASE_HEIGHT, 0, 0, 0.5, 0.3, 1); + const expectedFaceWidth = BODY_BASE_WIDTH * FACE_BASE_WIDTH_RATIO; + const expectedFaceHeight = BODY_BASE_HEIGHT * FACE_BASE_HEIGHT_RATIO; + expect(result.offsetX).toBeCloseTo(0.5 * expectedFaceWidth); + expect(result.offsetY).toBeCloseTo(0.3 * expectedFaceHeight); + }); }); diff --git a/src/entities/avatar/model/avatar.model.ts b/src/entities/avatar/model/avatar.model.ts index c23110dd..15b99741 100644 --- a/src/entities/avatar/model/avatar.model.ts +++ b/src/entities/avatar/model/avatar.model.ts @@ -1,8 +1,14 @@ +import { AvatarCompositionType } from '@/shared/api/http/types/@enums'; + export type Model = { /** * body의 uri */ bodyUri: string; + /** + * 아바타 합성 타입 (SINGLE_BODY | BODY_WITH_FACE) + */ + compositionType?: AvatarCompositionType; /** * face의 uri */ diff --git a/src/entities/avatar/ui/avatar.component.tsx b/src/entities/avatar/ui/avatar.component.tsx index 94cf0f38..6d334ec4 100644 --- a/src/entities/avatar/ui/avatar.component.tsx +++ b/src/entities/avatar/ui/avatar.component.tsx @@ -5,7 +5,7 @@ const ReactionLottie = dynamic( () => import('@/entities/avatar/ui/reaction-lottie').then((mod) => mod.ReactionLottie), { ssr: false } ); -import { MotionType, ReactionType } from '@/shared/api/http/types/@enums'; +import { AvatarCompositionType, MotionType, ReactionType } from '@/shared/api/http/types/@enums'; import { AvatarFacePos } from '@/shared/api/http/types/users'; import { cn } from '@/shared/lib/functions/cn'; import calculateDimensions from '../lib/calculate-dimensions'; @@ -39,6 +39,7 @@ const Avatar = memo( ({ height, bodyUri, + compositionType, faceUri, facePosX, facePosY, @@ -82,7 +83,7 @@ const Avatar = memo( )} - {faceUri && ( + {compositionType === AvatarCompositionType.BODY_WITH_FACE && faceUri && (
{ + describe('constructor', () => { + test('initialFacePos가 undefined이면 기본값 { offsetX: 0, offsetY: 0, scale: 1 }을 사용한다', () => { + const { helper, onFacePosChange } = createHelper(undefined); + // 기본값을 확인하기 위해 onScale 호출로 현재 facePos를 드러낸다 + helper.onScale({ scale: [2] }); + expect(onFacePosChange).toHaveBeenCalledWith({ offsetX: 0, offsetY: 0, scale: 2 }); + }); + + test('initialFacePos가 제공되면 그대로 저장한다', () => { + const initial = { offsetX: 0.5, offsetY: 0.3, scale: 1.5 }; + const { helper, onFacePosChange } = createHelper(initial); + // onScale로 scale만 변경하여 나머지 값 확인 + helper.onScale({ scale: [2] }); + expect(onFacePosChange).toHaveBeenCalledWith({ offsetX: 0.5, offsetY: 0.3, scale: 2 }); + }); + }); + + describe('onDrag', () => { + test('offsetX = x / faceWidth, offsetY = y / faceHeight로 계산한다', () => { + const { helper, onFacePosChange } = createHelper(); + helper.onDrag({ beforeTranslate: [100, 150] }); + expect(onFacePosChange).toHaveBeenCalledWith({ + offsetX: 100 / FACE_WIDTH, + offsetY: 150 / FACE_HEIGHT, + scale: 1, + }); + }); + + test('기존 scale 값을 유지한다', () => { + const { helper, onFacePosChange } = createHelper({ offsetX: 0, offsetY: 0, scale: 2.5 }); + helper.onDrag({ beforeTranslate: [50, 60] }); + expect(onFacePosChange).toHaveBeenCalledWith({ + offsetX: 50 / FACE_WIDTH, + offsetY: 60 / FACE_HEIGHT, + scale: 2.5, + }); + }); + }); + + describe('onScale', () => { + test('scale = zoomRatio로 설정하고 기존 offset을 유지한다', () => { + const initial = { offsetX: 0.4, offsetY: 0.6, scale: 1 }; + const { helper, onFacePosChange } = createHelper(initial); + helper.onScale({ scale: [3] }); + expect(onFacePosChange).toHaveBeenCalledWith({ offsetX: 0.4, offsetY: 0.6, scale: 3 }); + }); + }); + + test('drag → scale 연속 호출 시 모든 필드가 올바르게 유지된다', () => { + const { helper, onFacePosChange } = createHelper(); + + helper.onDrag({ beforeTranslate: [80, 120] }); + expect(onFacePosChange).toHaveBeenLastCalledWith({ + offsetX: 80 / FACE_WIDTH, + offsetY: 120 / FACE_HEIGHT, + scale: 1, + }); + + helper.onScale({ scale: [1.5] }); + expect(onFacePosChange).toHaveBeenLastCalledWith({ + offsetX: 80 / FACE_WIDTH, + offsetY: 120 / FACE_HEIGHT, + scale: 1.5, + }); + }); +}); diff --git a/src/entities/current-partyroom/lib/alerts/use-alert.hook.test.ts b/src/entities/current-partyroom/lib/alerts/use-alert.hook.test.ts new file mode 100644 index 00000000..e14e31bd --- /dev/null +++ b/src/entities/current-partyroom/lib/alerts/use-alert.hook.test.ts @@ -0,0 +1,33 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useAlert from './use-alert.hook'; + +const mockSubscribe = vi.fn(); +const mockUnsubscribe = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ alert: { subscribe: mockSubscribe, unsubscribe: mockUnsubscribe } }), + }); +}); + +describe('useAlert', () => { + test('마운트 시 callback을 subscribe한다', () => { + const callback = vi.fn(); + renderHook(() => useAlert(callback)); + + expect(mockSubscribe).toHaveBeenCalledWith(callback); + }); + + test('언마운트 시 callback을 unsubscribe한다', () => { + const callback = vi.fn(); + const { unmount } = renderHook(() => useAlert(callback)); + + unmount(); + expect(mockUnsubscribe).toHaveBeenCalledWith(callback); + }); +}); diff --git a/src/entities/current-partyroom/lib/alerts/use-grade-adjusted-alert.hook.test.tsx b/src/entities/current-partyroom/lib/alerts/use-grade-adjusted-alert.hook.test.tsx new file mode 100644 index 00000000..d7e1ab42 --- /dev/null +++ b/src/entities/current-partyroom/lib/alerts/use-grade-adjusted-alert.hook.test.tsx @@ -0,0 +1,33 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); + +import { renderHook, act } from '@testing-library/react'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import { useOpenGradeAdjustmentAlertDialog } from './use-grade-adjusted-alert.hook'; + +const mockOpenAlertDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + auth: { para: { auth_changed: 'Authority Changed' } }, + }); + (useDialog as Mock).mockReturnValue({ openAlertDialog: mockOpenAlertDialog }); +}); + +describe('useOpenGradeAdjustmentAlertDialog', () => { + test('등급 변경 알림 다이얼로그를 연다', () => { + const { result } = renderHook(() => useOpenGradeAdjustmentAlertDialog()); + + act(() => { + result.current(GradeType.CLUBBER, GradeType.MODERATOR); + }); + + expect(mockOpenAlertDialog).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Authority Changed' }) + ); + }); +}); diff --git a/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.test.tsx b/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.test.tsx new file mode 100644 index 00000000..c60f2b94 --- /dev/null +++ b/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.test.tsx @@ -0,0 +1,72 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('@/shared/lib/localization/renderer/index.ui', () => ({ + Trans: ({ i18nKey }: any) => {i18nKey}, +})); + +import { renderHook, act } from '@testing-library/react'; +import { PenaltyType } from '@/shared/api/http/types/@enums'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import usePenaltyAlert from './use-penalty-alert.hook'; + +const mockOpenDialog = vi.fn().mockResolvedValue(undefined); +const mockMarkExitedOnBackend = vi.fn(); +let alertCallback: (...args: any[]) => void; + +vi.mock('./use-alert.hook', () => ({ + __esModule: true, + default: vi.fn((cb: (...args: any[]) => void) => { + alertCallback = cb; + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + common: { para: { reason: 'Reason' }, btn: { confirm: 'Confirm' } }, + }); + (useDialog as Mock).mockReturnValue({ openDialog: mockOpenDialog }); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ markExitedOnBackend: mockMarkExitedOnBackend }), + }); + Object.defineProperty(window, 'location', { + value: { href: '/' }, + writable: true, + }); +}); + +describe('usePenaltyAlert', () => { + test('패널티 알림 메시지를 받으면 다이얼로그를 연다', async () => { + renderHook(() => usePenaltyAlert()); + + await act(async () => { + alertCallback({ type: PenaltyType.CHAT_BAN_30_SECONDS, reason: 'spam' }); + }); + + expect(mockOpenDialog).toHaveBeenCalled(); + }); + + test('강제 퇴장 패널티면 markExitedOnBackend를 호출한다', async () => { + renderHook(() => usePenaltyAlert()); + + await act(async () => { + alertCallback({ type: PenaltyType.ONE_TIME_EXPULSION, reason: 'rule violation' }); + }); + + expect(mockMarkExitedOnBackend).toHaveBeenCalled(); + }); + + test('등급 변경 메시지는 무시한다', async () => { + renderHook(() => usePenaltyAlert()); + + await act(async () => { + alertCallback({ type: 'grade-adjusted', prev: 'CLUBBER', next: 'MODERATOR' }); + }); + + expect(mockOpenDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.tsx b/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.tsx index 36023a08..07a0640b 100644 --- a/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.tsx +++ b/src/entities/current-partyroom/lib/alerts/use-penalty-alert.hook.tsx @@ -1,7 +1,8 @@ import { ReactNode, useCallback } from 'react'; import { PenaltyType } from '@/shared/api/http/types/@enums'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { Trans, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { useStores } from '@/shared/lib/store/stores.context'; import { Dialog, useDialog } from '@/shared/ui/components/dialog'; import { Typography } from '@/shared/ui/components/typography'; diff --git a/src/entities/current-partyroom/lib/use-remove-current-partyroom-caches.hook.test.ts b/src/entities/current-partyroom/lib/use-remove-current-partyroom-caches.hook.test.ts new file mode 100644 index 00000000..2aae3f6b --- /dev/null +++ b/src/entities/current-partyroom/lib/use-remove-current-partyroom-caches.hook.test.ts @@ -0,0 +1,48 @@ +vi.mock('@/shared/lib/store/stores.context'); + +const mockRemoveQueries = vi.fn(); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ removeQueries: mockRemoveQueries }), +})); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useRemoveCurrentPartyroomCaches from './use-remove-current-partyroom-caches.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useRemoveCurrentPartyroomCaches', () => { + test('현재 파티룸 ID에 해당하는 5개 캐시가 모두 제거된다', () => { + store.setState({ id: 42 }); + + const { result } = renderHook(() => useRemoveCurrentPartyroomCaches()); + result.current(); + + expect(mockRemoveQueries).toHaveBeenCalledTimes(5); + expect(mockRemoveQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.DjingQueue, 42] }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.Crews, 42] }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.PartyroomDetailSummary, 42], + }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.PlaybackHistory, 42] }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.Penalties, 42] }); + }); + + test('파티룸 ID가 undefined이면 undefined 키로 제거를 시도한다', () => { + const { result } = renderHook(() => useRemoveCurrentPartyroomCaches()); + result.current(); + + expect(mockRemoveQueries).toHaveBeenCalledTimes(5); + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.DjingQueue, undefined], + }); + }); +}); diff --git a/src/entities/current-partyroom/model/alert-message.model.test.ts b/src/entities/current-partyroom/model/alert-message.model.test.ts new file mode 100644 index 00000000..b2ecc27a --- /dev/null +++ b/src/entities/current-partyroom/model/alert-message.model.test.ts @@ -0,0 +1,48 @@ +import { GradeType, PenaltyType } from '@/shared/api/http/types/@enums'; +import { + isPenaltyAlertMessage, + isGradeAdjustedAlertMessage, + type Model, +} from './alert-message.model'; + +describe('alert-message model', () => { + describe('isPenaltyAlertMessage', () => { + it.each([ + PenaltyType.CHAT_BAN_30_SECONDS, + PenaltyType.ONE_TIME_EXPULSION, + PenaltyType.PERMANENT_EXPULSION, + ])('PenaltyType.%s → true', (type) => { + const message: Model = { type, reason: '규칙 위반' }; + expect(isPenaltyAlertMessage(message)).toBe(true); + }); + + test('grade-adjusted 타입은 false', () => { + const message: Model = { + type: 'grade-adjusted', + prev: GradeType.LISTENER, + next: GradeType.CLUBBER, + }; + expect(isPenaltyAlertMessage(message)).toBe(false); + }); + }); + + describe('isGradeAdjustedAlertMessage', () => { + test('grade-adjusted 타입은 true', () => { + const message: Model = { + type: 'grade-adjusted', + prev: GradeType.LISTENER, + next: GradeType.MODERATOR, + }; + expect(isGradeAdjustedAlertMessage(message)).toBe(true); + }); + + it.each([ + PenaltyType.CHAT_BAN_30_SECONDS, + PenaltyType.ONE_TIME_EXPULSION, + PenaltyType.PERMANENT_EXPULSION, + ])('PenaltyType.%s → false', (type) => { + const message: Model = { type, reason: '규칙 위반' }; + expect(isGradeAdjustedAlertMessage(message)).toBe(false); + }); + }); +}); diff --git a/src/entities/current-partyroom/model/chat-message.model.ts b/src/entities/current-partyroom/model/chat-message.model.ts index d2a6b5f5..ef0f6026 100644 --- a/src/entities/current-partyroom/model/chat-message.model.ts +++ b/src/entities/current-partyroom/model/chat-message.model.ts @@ -1,5 +1,5 @@ import { PartyroomCrew } from '@/shared/api/http/types/partyrooms'; -import { ChatEvent } from '@/shared/api/websocket/types/partyroom'; +import { ChatMessageSentEvent } from '@/shared/api/websocket/types/partyroom'; export type SystemChat = { from: 'system'; @@ -10,7 +10,7 @@ export type SystemChat = { export type UserChat = { from: 'user'; crew: PartyroomCrew; - message: ChatEvent['message']; + message: ChatMessageSentEvent['message']; receivedAt: number; }; diff --git a/src/entities/current-partyroom/model/crew.model.test.ts b/src/entities/current-partyroom/model/crew.model.test.ts new file mode 100644 index 00000000..2a7ba20b --- /dev/null +++ b/src/entities/current-partyroom/model/crew.model.test.ts @@ -0,0 +1,232 @@ +import { GradeType } from '@/shared/api/http/types/@enums'; +import { GradeComparator, Permission } from './crew.model'; + +// of()가 undefined를 반환할 수 있는 타입이므로, 테스트에서 타입 단언용 헬퍼 +const comparator = (base: GradeType) => GradeComparator.of(base) as GradeComparator; +const perm = (base: GradeType) => Permission.of(base) as Permission; + +describe('GradeComparator', () => { + beforeEach(() => { + // @ts-expect-error private 접근 — 싱글톤 캐시 초기화 + GradeComparator.instances = {}; + }); + + describe('isHigherThan', () => { + it.each([ + [GradeType.HOST, GradeType.MODERATOR, true], + [GradeType.CLUBBER, GradeType.LISTENER, true], + [GradeType.MODERATOR, GradeType.MODERATOR, false], + [GradeType.LISTENER, GradeType.HOST, false], + [GradeType.CLUBBER, GradeType.MODERATOR, false], + ])('%s > %s → %s', (base, target, expected) => { + expect(comparator(base).isHigherThan(target)).toBe(expected); + }); + }); + + describe('isHigherThanOrEqualTo', () => { + it.each([ + [GradeType.HOST, GradeType.HOST, true], + [GradeType.MODERATOR, GradeType.MODERATOR, true], + [GradeType.HOST, GradeType.MODERATOR, true], + [GradeType.LISTENER, GradeType.HOST, false], + ])('%s >= %s → %s', (base, target, expected) => { + expect(comparator(base).isHigherThanOrEqualTo(target)).toBe(expected); + }); + }); + + describe('isLowerThan', () => { + it.each([ + [GradeType.LISTENER, GradeType.HOST, true], + [GradeType.CLUBBER, GradeType.MODERATOR, true], + [GradeType.MODERATOR, GradeType.MODERATOR, false], + [GradeType.HOST, GradeType.LISTENER, false], + ])('%s < %s → %s', (base, target, expected) => { + expect(comparator(base).isLowerThan(target)).toBe(expected); + }); + }); + + describe('isLowerThanOrEqualTo', () => { + it.each([ + [GradeType.LISTENER, GradeType.HOST, true], + [GradeType.MODERATOR, GradeType.MODERATOR, true], + [GradeType.HOST, GradeType.LISTENER, false], + ])('%s <= %s → %s', (base, target, expected) => { + expect(comparator(base).isLowerThanOrEqualTo(target)).toBe(expected); + }); + }); + + describe('higherGrades', () => { + test('MODERATOR보다 높은 등급 반환', () => { + expect(comparator(GradeType.MODERATOR).higherGrades).toEqual([ + GradeType.HOST, + GradeType.COMMUNITY_MANAGER, + ]); + }); + + test('HOST보다 높은 등급은 없음', () => { + expect(comparator(GradeType.HOST).higherGrades).toEqual([]); + }); + }); + + describe('lowerGrades', () => { + test('MODERATOR보다 낮은 등급 반환', () => { + expect(comparator(GradeType.MODERATOR).lowerGrades).toEqual([ + GradeType.CLUBBER, + GradeType.LISTENER, + ]); + }); + + test('LISTENER보다 낮은 등급은 없음', () => { + expect(comparator(GradeType.LISTENER).lowerGrades).toEqual([]); + }); + }); + + describe('싱글톤 캐싱', () => { + test('같은 등급으로 of() 호출 시 동일 인스턴스 반환', () => { + const a = GradeComparator.of(GradeType.MODERATOR); + const b = GradeComparator.of(GradeType.MODERATOR); + expect(a).toBe(b); + }); + }); +}); + +describe('Permission', () => { + beforeEach(() => { + // @ts-expect-error private 접근 + Permission.instances = {}; + // @ts-expect-error private 접근 + GradeComparator.instances = {}; + }); + + describe('canAdjustGrade', () => { + it.each([ + [GradeType.HOST, GradeType.MODERATOR, true], + [GradeType.MODERATOR, GradeType.CLUBBER, true], + [GradeType.MODERATOR, GradeType.MODERATOR, false], + [GradeType.CLUBBER, GradeType.LISTENER, false], + [GradeType.LISTENER, GradeType.CLUBBER, false], + ])('%s가 %s 등급 조정 → %s', (base, target, expected) => { + expect(perm(base).canAdjustGrade(target)).toBe(expected); + }); + }); + + describe('canRemoveChatMessage', () => { + it.each([ + [GradeType.HOST, GradeType.CLUBBER, true], + [GradeType.MODERATOR, GradeType.LISTENER, true], + [GradeType.MODERATOR, GradeType.MODERATOR, false], + [GradeType.CLUBBER, GradeType.LISTENER, false], + ])('%s가 %s 채팅 삭제 → %s', (base, target, expected) => { + expect(perm(base).canRemoveChatMessage(target)).toBe(expected); + }); + }); + + describe('MODERATOR 이상이면 true인 권한들', () => { + const moderatorOrAbove = [GradeType.HOST, GradeType.COMMUNITY_MANAGER, GradeType.MODERATOR]; + const belowModerator = [GradeType.CLUBBER, GradeType.LISTENER]; + + it.each(moderatorOrAbove)('%s는 canViewPenalties true', (grade) => { + expect(perm(grade).canViewPenalties()).toBe(true); + }); + + it.each(belowModerator)('%s는 canViewPenalties false', (grade) => { + expect(perm(grade).canViewPenalties()).toBe(false); + }); + + it.each(moderatorOrAbove)('%s는 canSkipPlayback true', (grade) => { + expect(perm(grade).canSkipPlayback()).toBe(true); + }); + + it.each(belowModerator)('%s는 canSkipPlayback false', (grade) => { + expect(perm(grade).canSkipPlayback()).toBe(false); + }); + + it.each(moderatorOrAbove)('%s는 canLockDjingQueue true', (grade) => { + expect(perm(grade).canLockDjingQueue()).toBe(true); + }); + + it.each(belowModerator)('%s는 canLockDjingQueue false', (grade) => { + expect(perm(grade).canLockDjingQueue()).toBe(false); + }); + + it.each(moderatorOrAbove)('%s는 canUnlockDjingQueue true', (grade) => { + expect(perm(grade).canUnlockDjingQueue()).toBe(true); + }); + + it.each(belowModerator)('%s는 canUnlockDjingQueue false', (grade) => { + expect(perm(grade).canUnlockDjingQueue()).toBe(false); + }); + + it.each(moderatorOrAbove)('%s는 canDeleteDjFromQueue true', (grade) => { + expect(perm(grade).canDeleteDjFromQueue()).toBe(true); + }); + + it.each(belowModerator)('%s는 canDeleteDjFromQueue false', (grade) => { + expect(perm(grade).canDeleteDjFromQueue()).toBe(false); + }); + }); + + describe('HOST만 true인 권한들', () => { + test('HOST는 canEdit true', () => { + expect(perm(GradeType.HOST).canEdit()).toBe(true); + }); + + it.each([ + GradeType.COMMUNITY_MANAGER, + GradeType.MODERATOR, + GradeType.CLUBBER, + GradeType.LISTENER, + ])('%s는 canEdit false', (grade) => { + expect(perm(grade).canEdit()).toBe(false); + }); + + test('HOST는 canClose true', () => { + expect(perm(GradeType.HOST).canClose()).toBe(true); + }); + + it.each([ + GradeType.COMMUNITY_MANAGER, + GradeType.MODERATOR, + GradeType.CLUBBER, + GradeType.LISTENER, + ])('%s는 canClose false', (grade) => { + expect(perm(grade).canClose()).toBe(false); + }); + }); + + describe('미구현 메서드 Error throw', () => { + test('canRegisterDj 호출 시 에러 발생', () => { + expect(() => perm(GradeType.HOST).canRegisterDj()).toThrow('Not Impl yet'); + }); + + test('canUnregisterDj 호출 시 에러 발생', () => { + expect(() => perm(GradeType.HOST).canUnregisterDj()).toThrow('Not Impl yet'); + }); + }); + + describe('adjustableGrades', () => { + test('HOST의 adjustableGrades는 자기보다 낮은 모든 등급', () => { + expect(perm(GradeType.HOST).adjustableGrades).toEqual([ + GradeType.COMMUNITY_MANAGER, + GradeType.MODERATOR, + GradeType.CLUBBER, + GradeType.LISTENER, + ]); + }); + + test('MODERATOR의 adjustableGrades는 CLUBBER, LISTENER', () => { + expect(perm(GradeType.MODERATOR).adjustableGrades).toEqual([ + GradeType.CLUBBER, + GradeType.LISTENER, + ]); + }); + }); + + describe('싱글톤 캐싱', () => { + test('같은 등급으로 of() 호출 시 동일 인스턴스 반환', () => { + const a = Permission.of(GradeType.HOST); + const b = Permission.of(GradeType.HOST); + expect(a).toBe(b); + }); + }); +}); diff --git a/src/entities/current-partyroom/model/current-partyroom.store.test.ts b/src/entities/current-partyroom/model/current-partyroom.store.test.ts new file mode 100644 index 00000000..4ebe7466 --- /dev/null +++ b/src/entities/current-partyroom/model/current-partyroom.store.test.ts @@ -0,0 +1,356 @@ +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import type { PartyroomCrew } from '@/shared/api/http/types/partyrooms'; +import type * as ChatMessage from './chat-message.model'; +import type * as Crew from './crew.model'; +import { createCurrentPartyroomStore } from './current-partyroom.store'; + +/* ------------------------------------------------------------------ */ +/* Factory helpers */ +/* ------------------------------------------------------------------ */ + +const createPartyroomCrew = (overrides: Partial = {}): PartyroomCrew => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + ...overrides, +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + ...createPartyroomCrew(), + motionType: MotionType.NONE, + ...overrides, +}); + +const createSystemChat = ( + overrides: Partial = {} +): ChatMessage.SystemChat => ({ + from: 'system', + content: '시스템 메시지', + receivedAt: Date.now(), + ...overrides, +}); + +const createUserChat = (overrides: Partial = {}): ChatMessage.UserChat => ({ + from: 'user', + crew: createPartyroomCrew(), + message: '안녕하세요', + receivedAt: Date.now(), + ...overrides, +}); + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('current-partyroom store', () => { + test('초기 상태 검증', () => { + const store = createCurrentPartyroomStore(); + const state = store.getState(); + + expect(state.id).toBeUndefined(); + expect(state.exitedOnBackend).toBe(false); + expect(state.me).toBeUndefined(); + expect(state.playbackActivated).toBe(false); + expect(state.playback).toBeUndefined(); + expect(state.crews).toEqual([]); + expect(state.reaction).toEqual({ + history: { isLiked: false, isDisliked: false, isGrabbed: false }, + aggregation: { likeCount: 0, dislikeCount: 0, grabCount: 0 }, + motion: [], + }); + expect(state.currentDj).toBeUndefined(); + expect(state.notice).toBe(''); + expect(state.chat).toBeDefined(); + expect(state.alert).toBeDefined(); + }); + + describe('markExitedOnBackend', () => { + test('exitedOnBackend가 true로 변경된다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().markExitedOnBackend(); + + expect(store.getState().exitedOnBackend).toBe(true); + }); + }); + + describe('updateMe', () => { + test('me 정보를 부분 업데이트한다', () => { + const store = createCurrentPartyroomStore(); + const initial = { crewId: 1, gradeType: GradeType.CLUBBER }; + + // 먼저 init으로 me를 설정 + store.getState().init({ + id: 1, + me: initial, + playbackActivated: false, + crews: [], + notice: '', + }); + + store.getState().updateMe({ gradeType: GradeType.MODERATOR }); + + expect(store.getState().me).toEqual({ + crewId: 1, + gradeType: GradeType.MODERATOR, + }); + }); + }); + + describe('updatePlaybackActivated', () => { + test('playbackActivated 값을 변경한다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().updatePlaybackActivated(true); + + expect(store.getState().playbackActivated).toBe(true); + }); + }); + + describe('updatePlayback', () => { + test('playback 정보를 업데이트한다', () => { + const store = createCurrentPartyroomStore(); + const playback = { + id: 100, + name: '테스트 곡', + linkId: 'abc123', + duration: '03:45', + thumbnailImage: 'thumb.jpg', + endTime: 1722750394821, + }; + + store.getState().updatePlayback(() => playback); + + expect(store.getState().playback).toEqual(playback); + }); + + test('playback 정보를 부분 업데이트한다', () => { + const store = createCurrentPartyroomStore(); + const playback = { + id: 100, + name: '테스트 곡', + linkId: 'abc123', + duration: '03:45', + thumbnailImage: 'thumb.jpg', + endTime: 1722750394821, + }; + + store.getState().updatePlayback(() => playback); + store.getState().updatePlayback({ name: '변경된 곡' }); + + expect(store.getState().playback).toEqual({ + ...playback, + name: '변경된 곡', + }); + }); + }); + + describe('updateReaction', () => { + test('reaction aggregation을 업데이트한다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().updateReaction({ + aggregation: { likeCount: 5, dislikeCount: 2, grabCount: 1 }, + }); + + const reaction = store.getState().reaction; + expect(reaction.aggregation).toEqual({ + likeCount: 5, + dislikeCount: 2, + grabCount: 1, + }); + // history는 변경되지 않음 + expect(reaction.history).toEqual({ + isLiked: false, + isDisliked: false, + isGrabbed: false, + }); + }); + }); + + describe('updateCrews', () => { + test('crews 배열을 설정한다', () => { + const store = createCurrentPartyroomStore(); + const crews = [ + createCrew({ crewId: 1, nickname: '유저1' }), + createCrew({ crewId: 2, nickname: '유저2' }), + ]; + + store.getState().updateCrews(() => crews); + + expect(store.getState().crews).toHaveLength(2); + expect(store.getState().crews[0].nickname).toBe('유저1'); + expect(store.getState().crews[1].nickname).toBe('유저2'); + }); + }); + + describe('resetCrewsMotion', () => { + test('모든 크루의 motionType이 NONE으로 초기화된다', () => { + const store = createCurrentPartyroomStore(); + const crews = [ + createCrew({ crewId: 1, motionType: MotionType.DANCE_TYPE_1 }), + createCrew({ crewId: 2, motionType: MotionType.DANCE_TYPE_2 }), + ]; + + store.getState().updateCrews(() => crews); + store.getState().resetCrewsMotion(); + + const result = store.getState().crews; + expect(result[0].motionType).toBe(MotionType.NONE); + expect(result[1].motionType).toBe(MotionType.NONE); + }); + + test('crews가 비어있으면 빈 배열을 유지한다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().resetCrewsMotion(); + + expect(store.getState().crews).toEqual([]); + }); + }); + + describe('updateCurrentDj', () => { + test('현재 DJ를 설정한다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().updateCurrentDj({ crewId: 42 }); + + expect(store.getState().currentDj).toEqual({ crewId: 42 }); + }); + + test('현재 DJ를 undefined로 해제한다', () => { + const store = createCurrentPartyroomStore(); + store.getState().updateCurrentDj({ crewId: 42 }); + + store.getState().updateCurrentDj(undefined); + + expect(store.getState().currentDj).toBeUndefined(); + }); + }); + + describe('updateNotice', () => { + test('공지사항 문자열을 설정한다', () => { + const store = createCurrentPartyroomStore(); + + store.getState().updateNotice('새로운 공지사항입니다'); + + expect(store.getState().notice).toBe('새로운 공지사항입니다'); + }); + }); + + describe('appendChatMessage', () => { + test('채팅 메시지를 추가한다', () => { + const store = createCurrentPartyroomStore(); + const message = createSystemChat({ content: '환영합니다' }); + + store.getState().appendChatMessage(message); + + const messages = store.getState().chat.getMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual(message); + }); + + test('여러 메시지를 순서대로 추가한다', () => { + const store = createCurrentPartyroomStore(); + const msg1 = createSystemChat({ content: '메시지1', receivedAt: 1000 }); + const msg2 = createUserChat({ message: '메시지2', receivedAt: 2000 }); + + store.getState().appendChatMessage(msg1); + store.getState().appendChatMessage(msg2); + + const messages = store.getState().chat.getMessages(); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(msg1); + expect(messages[1]).toEqual(msg2); + }); + }); + + describe('updateChatMessage', () => { + test('조건에 맞는 메시지를 업데이트한다', () => { + const store = createCurrentPartyroomStore(); + const msg = createSystemChat({ content: '원본 메시지', receivedAt: 1000 }); + + store.getState().appendChatMessage(msg); + store.getState().updateChatMessage( + (m) => m.from === 'system' && m.content === '원본 메시지', + (m) => ({ ...m, content: '수정된 메시지' }) as ChatMessage.Model + ); + + const messages = store.getState().chat.getMessages(); + expect(messages[0]).toEqual(expect.objectContaining({ content: '수정된 메시지' })); + }); + }); + + describe('init', () => { + test('초기 상태를 리셋하고 전달된 값으로 병합한다', () => { + const store = createCurrentPartyroomStore(); + // 먼저 상태를 변경 + store.getState().updateNotice('이전 공지'); + store.getState().markExitedOnBackend(); + + const crews = [createCrew({ crewId: 10 })]; + store.getState().init({ + id: 99, + me: { crewId: 10, gradeType: GradeType.HOST }, + playbackActivated: true, + crews, + notice: '새 공지', + }); + + const state = store.getState(); + expect(state.id).toBe(99); + expect(state.me).toEqual({ crewId: 10, gradeType: GradeType.HOST }); + expect(state.playbackActivated).toBe(true); + expect(state.crews).toEqual(crews); + expect(state.notice).toBe('새 공지'); + // 초기 상태로 리셋된 값 + expect(state.exitedOnBackend).toBe(false); + }); + }); + + describe('reset', () => { + test('모든 상태를 초기 상태로 복원한다', () => { + const store = createCurrentPartyroomStore(); + // 상태를 변경 + store.getState().init({ + id: 99, + me: { crewId: 10, gradeType: GradeType.HOST }, + playbackActivated: true, + crews: [createCrew()], + notice: '공지사항', + }); + store.getState().markExitedOnBackend(); + + store.getState().reset(); + + const state = store.getState(); + expect(state.id).toBeUndefined(); + expect(state.exitedOnBackend).toBe(false); + expect(state.me).toBeUndefined(); + expect(state.playbackActivated).toBe(false); + expect(state.playback).toBeUndefined(); + expect(state.crews).toEqual([]); + expect(state.notice).toBe(''); + expect(state.currentDj).toBeUndefined(); + }); + + test('reset 후 chat의 메시지가 비워진다', () => { + const store = createCurrentPartyroomStore(); + store.getState().appendChatMessage(createSystemChat()); + + const chatRef = store.getState().chat; + store.getState().reset(); + + // chat.clear()가 호출되어 메시지가 비워짐 + expect(chatRef.getMessages()).toHaveLength(0); + }); + }); +}); diff --git a/src/entities/current-partyroom/model/dj.model.test.ts b/src/entities/current-partyroom/model/dj.model.test.ts new file mode 100644 index 00000000..6effc3d7 --- /dev/null +++ b/src/entities/current-partyroom/model/dj.model.test.ts @@ -0,0 +1,34 @@ +import type { Dj } from '@/shared/api/http/types/partyrooms'; +import { toListItemConfig } from './dj.model'; + +describe('dj model', () => { + describe('toListItemConfig', () => { + test('Dj 모델을 DjListItemUserConfig로 변환', () => { + const dj: Dj = { + crewId: 1, + orderNumber: 1, + nickname: 'DJ테스트', + avatarIconUri: '/images/avatar.png', + }; + + expect(toListItemConfig(dj)).toEqual({ + username: 'DJ테스트', + src: '/images/avatar.png', + }); + }); + + test('빈 문자열 필드도 정상 변환', () => { + const dj: Dj = { + crewId: 2, + orderNumber: 3, + nickname: '', + avatarIconUri: '', + }; + + expect(toListItemConfig(dj)).toEqual({ + username: '', + src: '', + }); + }); + }); +}); diff --git a/src/entities/current-partyroom/model/playback.model.test.ts b/src/entities/current-partyroom/model/playback.model.test.ts index 67192364..b9891b37 100644 --- a/src/entities/current-partyroom/model/playback.model.test.ts +++ b/src/entities/current-partyroom/model/playback.model.test.ts @@ -6,11 +6,11 @@ describe('playback model', () => { const MOCK_CURRENT_DATE = new Date('2000-01-01T23:55:00Z'); beforeAll(() => { - jest.useFakeTimers().setSystemTime(MOCK_CURRENT_DATE); + vi.useFakeTimers().setSystemTime(MOCK_CURRENT_DATE); }); afterAll(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test('should return seek amount in seconds when endTime is in today', () => { @@ -34,5 +34,60 @@ describe('playback model', () => { expect(result).toBe((ONE_MINUTE * 3) / 1000); }); + + test('재생 시작 직후 (seek ≈ 0)', () => { + const model: Partial = { + duration: '03:00', + endTime: MOCK_CURRENT_DATE.getTime() + 3 * ONE_MINUTE, + }; + + const result = Playback.getInitialSeek(model as Playback.Model); + + expect(result).toBe(0); + }); + + test('거의 끝남 (seek ≈ duration)', () => { + const model: Partial = { + duration: '03:00', + endTime: MOCK_CURRENT_DATE.getTime() + 1000, + }; + + const result = Playback.getInitialSeek(model as Playback.Model); + + expect(result).toBe(179); + }); + + test('endTime 과거 → 음수 방지 (max 0)', () => { + const model: Partial = { + duration: '00:10', + endTime: MOCK_CURRENT_DATE.getTime() - ONE_MINUTE, + }; + + const result = Playback.getInitialSeek(model as Playback.Model); + + expect(result).toBeGreaterThanOrEqual(0); + }); + + test('duration "00:30" → 30초 기준 계산', () => { + const model: Partial = { + duration: '00:30', + endTime: MOCK_CURRENT_DATE.getTime() + 10 * 1000, + }; + + const result = Playback.getInitialSeek(model as Playback.Model); + + expect(result).toBe(20); + }); + + test('duration "05:00" → 300초 기준 계산', () => { + const model: Partial = { + duration: '05:00', + endTime: MOCK_CURRENT_DATE.getTime() + ONE_MINUTE, + }; + + const result = Playback.getInitialSeek(model as Playback.Model); + + expect(result).toBe(240); + }); }); }); diff --git a/src/entities/me/api/use-fetch-me-async.test.ts b/src/entities/me/api/use-fetch-me-async.test.ts new file mode 100644 index 00000000..1100215b --- /dev/null +++ b/src/entities/me/api/use-fetch-me-async.test.ts @@ -0,0 +1,30 @@ +import '@/shared/api/__test__/msw-server'; +import { act } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useFetchMeAsync } from './use-fetch-me-async'; + +describe('useFetchMeAsync 통합', () => { + test('fetchQuery 실행 후 Me 데이터를 반환한다', async () => { + const { result } = renderWithClient(() => useFetchMeAsync()); + + let meData: any; + await act(async () => { + meData = await result.current(); + }); + + expect(meData).toHaveProperty('uid', 'user-123'); + expect(meData).toHaveProperty('nickname', 'TestUser'); + }); + + test('캐시에 데이터가 저장된다', async () => { + const { result, queryClient } = renderWithClient(() => useFetchMeAsync()); + + await act(async () => { + await result.current(); + }); + + const cached = queryClient.getQueryData([QueryKeys.Me]); + expect(cached).toBeDefined(); + }); +}); diff --git a/src/entities/me/api/use-fetch-me.integration.test.ts b/src/entities/me/api/use-fetch-me.integration.test.ts new file mode 100644 index 00000000..f50d3948 --- /dev/null +++ b/src/entities/me/api/use-fetch-me.integration.test.ts @@ -0,0 +1,74 @@ +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { useFetchMe } from './use-fetch-me.query'; + +describe('useFetchMe integration (query → usersService → MSW)', () => { + it('returns combined me info + profile summary', async () => { + const { result } = renderWithClient(() => useFetchMe()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const data = result.current.data; + expect(data?.uid).toBe('user-123'); + expect(data?.email).toBe('test@pfplay.io'); + expect(data?.authorityTier).toBe('FM'); + expect(data?.nickname).toBe('TestUser'); + expect(data?.avatarBodyUri).toBe('https://example.com/body.png'); + }); + + it('transitions to error state on 401 auth error', async () => { + server.use( + http.get('http://localhost:8080/api/v1/users/me/info', () => { + return HttpResponse.json( + { + data: { + status: 'UNAUTHORIZED', + code: 401, + message: 'ACCESS_TOKEN 을 찾을 수 없음', + errorCode: 'JWT-001', + }, + }, + { status: 401 } + ); + }) + ); + + const { result } = renderWithClient(() => useFetchMe()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error?.isAxiosError).toBe(true); + expect(result.current.error?.response?.status).toBe(401); + }); + + it('emits errorCode via errorEmitter on API error', async () => { + server.use( + http.get('http://localhost:8080/api/v1/users/me/info', () => { + return HttpResponse.json( + { + data: { + status: 'UNAUTHORIZED', + code: 401, + message: 'ACCESS_TOKEN 이 만료됨', + errorCode: 'JWT-003', + }, + }, + { status: 401 } + ); + }) + ); + + const emitted: string[] = []; + const unsub = errorEmitter.on('JWT-003' as any, () => emitted.push('JWT-003')); + + const { result } = renderWithClient(() => useFetchMe()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(emitted).toContain('JWT-003'); + + unsub(); + }); +}); diff --git a/src/entities/me/api/use-prefetch-me.integration.test.ts b/src/entities/me/api/use-prefetch-me.integration.test.ts new file mode 100644 index 00000000..a4ed6279 --- /dev/null +++ b/src/entities/me/api/use-prefetch-me.integration.test.ts @@ -0,0 +1,24 @@ +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import usePrefetchMe from './use-prefetch-me.query'; + +describe('usePrefetchMe 통합', () => { + test('prefetch 실행 후 Me 캐시에 데이터가 채워진다', async () => { + const { result, queryClient } = renderWithClient(() => usePrefetchMe()); + + await act(async () => { + await result.current(); + }); + + await waitFor(() => { + const cached = queryClient.getQueryData([QueryKeys.Me]); + expect(cached).toBeDefined(); + }); + + const cached = queryClient.getQueryData([QueryKeys.Me]); + expect(cached).toHaveProperty('uid'); + expect(cached).toHaveProperty('nickname'); + }); +}); diff --git a/src/entities/me/index.ts b/src/entities/me/index.ts index d3710c95..b827a8d3 100644 --- a/src/entities/me/index.ts +++ b/src/entities/me/index.ts @@ -4,4 +4,3 @@ export { useFetchMe, useSuspenseFetchMe } from './api/use-fetch-me.query'; export { default as usePrefetchMe } from './api/use-prefetch-me.query'; export { useFetchMeAsync } from './api/use-fetch-me-async'; export { useGetMyServiceEntry } from './api/use-get-my-service-entry'; -export { default as MeHydration } from './ui/hydration.component'; diff --git a/src/entities/me/lib/use-is-guest.hook.test.ts b/src/entities/me/lib/use-is-guest.hook.test.ts new file mode 100644 index 00000000..251d7633 --- /dev/null +++ b/src/entities/me/lib/use-is-guest.hook.test.ts @@ -0,0 +1,41 @@ +const mockFetchMeAsync = vi.fn(); +vi.mock('@/entities/me', () => ({ + useFetchMeAsync: () => mockFetchMeAsync, +})); + +import { renderHook } from '@testing-library/react'; +import { AuthorityTier } from '@/shared/api/http/types/@enums'; +import useIsGuest from './use-is-guest.hook'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useIsGuest', () => { + test('AuthorityTier가 GT이면 true를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ authorityTier: AuthorityTier.GT }); + + const { result } = renderHook(() => useIsGuest()); + const isGuest = await result.current(); + + expect(isGuest).toBe(true); + }); + + test('AuthorityTier가 FM이면 false를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ authorityTier: AuthorityTier.FM }); + + const { result } = renderHook(() => useIsGuest()); + const isGuest = await result.current(); + + expect(isGuest).toBe(false); + }); + + test('AuthorityTier가 AM이면 false를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ authorityTier: AuthorityTier.AM }); + + const { result } = renderHook(() => useIsGuest()); + const isGuest = await result.current(); + + expect(isGuest).toBe(false); + }); +}); diff --git a/src/entities/me/model/me.model.test.ts b/src/entities/me/model/me.model.test.ts new file mode 100644 index 00000000..021042d6 --- /dev/null +++ b/src/entities/me/model/me.model.test.ts @@ -0,0 +1,72 @@ +import { ActivityType, AuthorityTier } from '@/shared/api/http/types/@enums'; +import type { Model } from './me.model'; +import { serviceEntry, score, registrationDate } from './me.model'; + +const createModel = (overrides: Partial = {}): Model => ({ + uid: 'test-uid', + authorityTier: AuthorityTier.FM, + registrationDate: '2024-06-23', + profileUpdated: true, + nickname: 'tester', + avatarBodyUri: '', + avatarFaceUri: '', + avatarIconUri: '', + activitySummaries: [], + offsetX: 0, + offsetY: 0, + scale: 1, + ...overrides, +}); + +describe('me model', () => { + describe('serviceEntry', () => { + test('null이면 루트 경로 반환', () => { + expect(serviceEntry(null)).toBe('/'); + }); + + test('프로필 미완성이면 설정 페이지 반환', () => { + const model = createModel({ profileUpdated: false }); + expect(serviceEntry(model)).toBe('/settings/profile'); + }); + + test('프로필 완성이면 파티 목록 반환', () => { + const model = createModel({ profileUpdated: true }); + expect(serviceEntry(model)).toBe('/parties'); + }); + }); + + describe('score', () => { + test('activityType이 summaries에 존재하면 해당 score 반환', () => { + const model = createModel({ + activitySummaries: [ + { activityType: ActivityType.DJ_PNT, score: 150 }, + { activityType: ActivityType.REF_LINK, score: 30 }, + ], + }); + expect(score(model, ActivityType.DJ_PNT)).toBe(150); + }); + + test('activityType이 summaries에 미존재하면 0 반환', () => { + const model = createModel({ + activitySummaries: [{ activityType: ActivityType.DJ_PNT, score: 150 }], + }); + expect(score(model, ActivityType.REF_LINK)).toBe(0); + }); + + test('activitySummaries 빈 배열이면 0 반환', () => { + const model = createModel({ activitySummaries: [] }); + expect(score(model, ActivityType.DJ_PNT)).toBe(0); + }); + }); + + describe('registrationDate', () => { + it.each([ + ['2024-06-23', '2024.06.23'], + ['2023-01-05', '2023.01.05'], + ['2025-12-31', '2025.12.31'], + ])('%s → %s', (input, expected) => { + const model = createModel({ registrationDate: input }); + expect(registrationDate(model)).toBe(expected); + }); + }); +}); diff --git a/src/entities/me/ui/hydration.component.tsx b/src/entities/me/ui/hydration.component.tsx deleted file mode 100644 index 7142543f..00000000 --- a/src/entities/me/ui/hydration.component.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { PropsWithChildren } from 'react'; -import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query'; -import { queryOptions } from '../api/use-fetch-me.query'; -import * as Me from '../model/me.model'; - -/** - * @see https://tanstack.com/query/latest/docs/react/guides/advanced-ssr#prefetching-and-dehydrating-data - */ -export default async function MeHydration({ children }: PropsWithChildren) { - const queryClient = new QueryClient(); - - await queryClient.prefetchQuery({ - queryKey: queryOptions.queryKey, - queryFn: queryOptions.queryFn, - }); - - return {children}; -} diff --git a/src/entities/music-preview/index.ts b/src/entities/music-preview/index.ts index 803e8d9a..4d3f34b5 100644 --- a/src/entities/music-preview/index.ts +++ b/src/entities/music-preview/index.ts @@ -1,9 +1,3 @@ export * as Preview from './model/preview.model'; export { createPreviewStore } from './model/preview.store'; export * from './lib/preview-helpers'; - -// UI Components -export { default as ThumbnailWithPreview } from './ui/thumbnail-with-preview.component'; -export { default as PreviewOverlay } from './ui/preview-overlay.component'; -export { default as PreviewIndicator } from './ui/preview-indicator.component'; -export { default as YouTubePreviewPlayer } from './ui/youtube-preview-player.component'; diff --git a/src/entities/music-preview/index.ui.ts b/src/entities/music-preview/index.ui.ts new file mode 100644 index 00000000..3dfa3c91 --- /dev/null +++ b/src/entities/music-preview/index.ui.ts @@ -0,0 +1,4 @@ +export { default as ThumbnailWithPreview } from './ui/thumbnail-with-preview.component'; +export { default as PreviewOverlay } from './ui/preview-overlay.component'; +export { default as PreviewIndicator } from './ui/preview-indicator.component'; +export { default as YouTubePreviewPlayer } from './ui/youtube-preview-player.component'; diff --git a/src/entities/music-preview/lib/preview-helpers.test.ts b/src/entities/music-preview/lib/preview-helpers.test.ts new file mode 100644 index 00000000..fea6b454 --- /dev/null +++ b/src/entities/music-preview/lib/preview-helpers.test.ts @@ -0,0 +1,78 @@ +import type { PlaylistTrack } from '@/shared/api/http/types/playlists'; +import type { Music } from '@/shared/api/http/types/playlists'; +import { + convertPlaylistTrackToPreview, + convertSearchMusicToPreview, + extractVideoIdFromUrl, + safeDecodeTitle, +} from './preview-helpers'; + +describe('preview-helpers', () => { + describe('convertPlaylistTrackToPreview', () => { + test('PlaylistTrack을 PreviewTrack으로 변환', () => { + const track: PlaylistTrack = { + trackId: 1, + linkId: '12345', + name: '테스트 곡', + orderNumber: 1, + duration: '03:30', + thumbnailImage: 'https://img.youtube.com/vi/12345/0.jpg', + }; + + expect(convertPlaylistTrackToPreview(track)).toEqual({ + id: '12345', + title: '테스트 곡', + thumbnailUrl: 'https://img.youtube.com/vi/12345/0.jpg', + videoUrl: 'https://www.youtube.com/watch?v=12345', + source: 'playlist-track', + }); + }); + }); + + describe('convertSearchMusicToPreview', () => { + test('Music을 PreviewTrack으로 변환', () => { + const music: Music = { + videoId: 'abc123', + videoTitle: '검색 결과 곡', + thumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + runningTime: '04:15', + }; + + expect(convertSearchMusicToPreview(music)).toEqual({ + id: 'abc123', + title: '검색 결과 곡', + thumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + videoUrl: 'https://www.youtube.com/watch?v=abc123', + source: 'search-result', + }); + }); + }); + + describe('extractVideoIdFromUrl', () => { + it.each([ + ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/watch?v=abc123&t=10', 'abc123'], + ])('"%s" → "%s"', (url, expected) => { + expect(extractVideoIdFromUrl(url)).toBe(expected); + }); + + it.each(['https://www.google.com', 'not-a-url', ''])('유효하지 않은 URL "%s" → null', (url) => { + expect(extractVideoIdFromUrl(url)).toBeNull(); + }); + }); + + describe('safeDecodeTitle', () => { + test('인코딩된 문자열 디코딩', () => { + expect(safeDecodeTitle('%ED%85%8C%EC%8A%A4%ED%8A%B8')).toBe('테스트'); + }); + + test('일반 문자열은 그대로 반환', () => { + expect(safeDecodeTitle('일반 텍스트')).toBe('일반 텍스트'); + }); + + test('잘못된 인코딩은 원본 반환', () => { + expect(safeDecodeTitle('%E0%A4%A')).toBe('%E0%A4%A'); + }); + }); +}); diff --git a/src/entities/music-preview/lib/preview-helpers.ts b/src/entities/music-preview/lib/preview-helpers.ts index 15b28ebb..afbecb72 100644 --- a/src/entities/music-preview/lib/preview-helpers.ts +++ b/src/entities/music-preview/lib/preview-helpers.ts @@ -6,7 +6,7 @@ import type { PreviewTrack } from '../model/preview.model'; * 플레이리스트 트랙을 미리보기 트랙으로 변환 */ export const convertPlaylistTrackToPreview = (track: PlaylistTrack): PreviewTrack => ({ - id: track.linkId.toString(), + id: track.linkId, title: track.name, thumbnailUrl: track.thumbnailImage, videoUrl: `https://www.youtube.com/watch?v=${track.linkId}`, diff --git a/src/entities/music-preview/lib/react-player.api.test.ts b/src/entities/music-preview/lib/react-player.api.test.ts new file mode 100644 index 00000000..fb196a79 --- /dev/null +++ b/src/entities/music-preview/lib/react-player.api.test.ts @@ -0,0 +1,153 @@ +import { ReactPlayerAPI } from './react-player.api'; + +function createMockPlayer(overrides?: Record) { + const internalPlayer = { + mute: vi.fn(), + unMute: vi.fn(), + setVolume: vi.fn(), + }; + return { + seekTo: vi.fn(), + forceUpdate: vi.fn(), + getCurrentTime: vi.fn(() => 42), + getDuration: vi.fn(() => 180), + getInternalPlayer: vi.fn(() => internalPlayer), + __internalPlayer: internalPlayer, + ...overrides, + } as any; +} + +describe('ReactPlayerAPI', () => { + let api: ReactPlayerAPI; + + beforeEach(() => { + api = new ReactPlayerAPI(); + }); + + describe('isReady', () => { + test('player 설정 전에는 false를 반환한다', () => { + expect(api.isReady()).toBe(false); + }); + + test('player 설정 후에는 true를 반환한다', () => { + api.setPlayer(createMockPlayer()); + expect(api.isReady()).toBe(true); + }); + + test('cleanup 후에는 false를 반환한다', () => { + api.setPlayer(createMockPlayer()); + api.cleanup(); + expect(api.isReady()).toBe(false); + }); + }); + + describe('play', () => { + test('player가 null이면 아무 동작도 하지 않는다', () => { + expect(() => api.play()).not.toThrow(); + }); + + test('player가 있으면 seekTo(0)과 forceUpdate를 호출한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.play(); + expect(player.seekTo).toHaveBeenCalledWith(0, 'seconds'); + expect(player.forceUpdate).toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + test('player가 null이면 아무 동작도 하지 않는다', () => { + expect(() => api.stop()).not.toThrow(); + }); + + test('player가 있으면 seekTo(0)을 호출한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.stop(); + expect(player.seekTo).toHaveBeenCalledWith(0, 'seconds'); + }); + }); + + describe('setMuted', () => { + test('muted=true → mute()를 호출한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.setMuted(true); + expect(player.__internalPlayer.mute).toHaveBeenCalled(); + }); + + test('muted=false → unMute()를 호출한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.setMuted(false); + expect(player.__internalPlayer.unMute).toHaveBeenCalled(); + }); + + test('internalPlayer가 null이면 아무 동작도 하지 않는다', () => { + const player = createMockPlayer({ getInternalPlayer: vi.fn(() => null) }); + api.setPlayer(player); + expect(() => api.setMuted(true)).not.toThrow(); + }); + + test('mute가 함수가 아니면 아무 동작도 하지 않는다', () => { + const player = createMockPlayer({ + getInternalPlayer: vi.fn(() => ({ mute: 'not-a-function' })), + }); + api.setPlayer(player); + expect(() => api.setMuted(true)).not.toThrow(); + }); + }); + + describe('setVolume', () => { + test('유효한 볼륨 값을 설정한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.setVolume(50); + expect(player.__internalPlayer.setVolume).toHaveBeenCalledWith(50); + }); + + test('150 → 100으로 클램핑한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.setVolume(150); + expect(player.__internalPlayer.setVolume).toHaveBeenCalledWith(100); + }); + + test('-5 → 0으로 클램핑한다', () => { + const player = createMockPlayer(); + api.setPlayer(player); + api.setVolume(-5); + expect(player.__internalPlayer.setVolume).toHaveBeenCalledWith(0); + }); + }); + + describe('getCurrentTime', () => { + test('player가 null이면 0을 반환한다', () => { + expect(api.getCurrentTime()).toBe(0); + }); + + test('player가 있으면 위임값을 반환한다', () => { + api.setPlayer(createMockPlayer()); + expect(api.getCurrentTime()).toBe(42); + }); + }); + + describe('getDuration', () => { + test('player가 null이면 0을 반환한다', () => { + expect(api.getDuration()).toBe(0); + }); + + test('player가 있으면 위임값을 반환한다', () => { + api.setPlayer(createMockPlayer()); + expect(api.getDuration()).toBe(180); + }); + }); + + describe('cleanup', () => { + test('player를 null로 초기화한다', () => { + api.setPlayer(createMockPlayer()); + api.cleanup(); + expect(api.isReady()).toBe(false); + }); + }); +}); diff --git a/src/entities/music-preview/model/preview.store.test.ts b/src/entities/music-preview/model/preview.store.test.ts new file mode 100644 index 00000000..52d8d770 --- /dev/null +++ b/src/entities/music-preview/model/preview.store.test.ts @@ -0,0 +1,109 @@ +import type { PreviewTrack } from './preview.model'; +import { createPreviewStore } from './preview.store'; + +const createTrack = (overrides: Partial = {}): PreviewTrack => ({ + id: 'track-1', + title: '테스트 곡', + thumbnailUrl: 'https://img.youtube.com/vi/test/0.jpg', + videoUrl: 'https://www.youtube.com/watch?v=test', + source: 'playlist-track', + ...overrides, +}); + +describe('preview store', () => { + test('초기 상태', () => { + const store = createPreviewStore(); + const state = store.getState(); + + expect(state.currentTrack).toBeNull(); + expect(state.playState).toBe('stopped'); + expect(state.playerReady).toBe(false); + }); + + describe('startPreview', () => { + test('트랙 재생 시작', () => { + const store = createPreviewStore(); + const track = createTrack(); + + store.getState().startPreview(track); + const state = store.getState(); + + expect(state.currentTrack).toEqual(track); + expect(state.playState).toBe('playing'); + expect(state.playerReady).toBe(false); + }); + + test('같은 트랙이 이미 재생중이면 상태 변경 없음', () => { + const store = createPreviewStore(); + const track = createTrack(); + + store.getState().startPreview(track); + store.getState().setPlayerReady(true); + + store.getState().startPreview(track); + + expect(store.getState().playerReady).toBe(true); + }); + + test('다른 트랙으로 전환', () => { + const store = createPreviewStore(); + const track1 = createTrack({ id: 'track-1', title: '곡1' }); + const track2 = createTrack({ id: 'track-2', title: '곡2' }); + + store.getState().startPreview(track1); + store.getState().startPreview(track2); + + expect(store.getState().currentTrack).toEqual(track2); + expect(store.getState().playerReady).toBe(false); + }); + }); + + describe('stopPreview', () => { + test('재생 중단', () => { + const store = createPreviewStore(); + store.getState().startPreview(createTrack()); + + store.getState().stopPreview(); + const state = store.getState(); + + expect(state.currentTrack).toBeNull(); + expect(state.playState).toBe('stopped'); + expect(state.playerReady).toBe(false); + }); + }); + + describe('setPlayerReady', () => { + test('플레이어 준비 상태 설정', () => { + const store = createPreviewStore(); + store.getState().startPreview(createTrack()); + + store.getState().setPlayerReady(true); + + expect(store.getState().playerReady).toBe(true); + }); + }); + + describe('isTrackPlaying', () => { + test('재생중인 트랙 ID와 일치하면 true', () => { + const store = createPreviewStore(); + store.getState().startPreview(createTrack({ id: 'abc' })); + + expect(store.getState().isTrackPlaying('abc')).toBe(true); + }); + + test('다른 트랙 ID면 false', () => { + const store = createPreviewStore(); + store.getState().startPreview(createTrack({ id: 'abc' })); + + expect(store.getState().isTrackPlaying('xyz')).toBe(false); + }); + + test('재생 중지 상태면 false', () => { + const store = createPreviewStore(); + store.getState().startPreview(createTrack({ id: 'abc' })); + store.getState().stopPreview(); + + expect(store.getState().isTrackPlaying('abc')).toBe(false); + }); + }); +}); diff --git a/src/entities/music-preview/ui/preview-overlay.component.test.tsx b/src/entities/music-preview/ui/preview-overlay.component.test.tsx new file mode 100644 index 00000000..97bf57d6 --- /dev/null +++ b/src/entities/music-preview/ui/preview-overlay.component.test.tsx @@ -0,0 +1,79 @@ +import { render, fireEvent } from '@testing-library/react'; +import PreviewOverlay from './preview-overlay.component'; + +describe('PreviewOverlay', () => { + const defaultProps = { + isPlaying: false, + onPlay: vi.fn(), + onStop: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('초기 상태에서 오버레이 내부 컨텐츠가 숨겨져 있다', () => { + const { container } = render(); + + const root = container.firstElementChild as HTMLElement; + expect(root.querySelector('.bg-white')).toBeNull(); + }); + + test('hover 시 오버레이 내부가 표시된다', () => { + const { container } = render(); + + const root = container.firstElementChild as HTMLElement; + fireEvent.mouseEnter(root); + + expect(root.querySelector('.bg-white')).not.toBeNull(); + }); + + test('unhover 시 오버레이 내부가 다시 숨겨진다', () => { + const { container } = render(); + + const root = container.firstElementChild as HTMLElement; + fireEvent.mouseEnter(root); + expect(root.querySelector('.bg-white')).not.toBeNull(); + + fireEvent.mouseLeave(root); + expect(root.querySelector('.bg-white')).toBeNull(); + }); + + test('isPlaying=false 상태에서 클릭하면 onPlay 콜백이 호출된다', () => { + const onPlay = vi.fn(); + const { container } = render( + + ); + + const root = container.firstElementChild as HTMLElement; + fireEvent.click(root); + + expect(onPlay).toHaveBeenCalledTimes(1); + }); + + test('isPlaying=true 상태에서 클릭하면 onStop 콜백이 호출된다', () => { + const onStop = vi.fn(); + const { container } = render( + + ); + + const root = container.firstElementChild as HTMLElement; + fireEvent.click(root); + + expect(onStop).toHaveBeenCalledTimes(1); + }); + + test('클릭 시 이벤트 전파가 차단된다 (stopPropagation)', () => { + const outerHandler = vi.fn(); + const { container } = render( +
+ +
+ ); + + const overlay = container.querySelector('.cursor-pointer') as HTMLElement; + fireEvent.click(overlay); + + expect(outerHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entities/music-preview/ui/youtube-preview-player.component.tsx b/src/entities/music-preview/ui/youtube-preview-player.component.tsx index 7bf31ea4..9ca19345 100644 --- a/src/entities/music-preview/ui/youtube-preview-player.component.tsx +++ b/src/entities/music-preview/ui/youtube-preview-player.component.tsx @@ -138,7 +138,6 @@ export default function YouTubePreviewPlayer({ {/* YouTube 플레이어 */} ({ + specificLog: vi.fn(), + warnLog: vi.fn(), +})); + +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + default: () => (fn: any) => fn, +})); + +vi.mock('./subscription-callbacks/use-chat-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-crew-grade-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-crew-penalty-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-crew-profile-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-crew-entered-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-crew-exited-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-partyroom-close-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-partyroom-deactivation-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-partyroom-notice-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-playback-start-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-reaction-aggregation-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-reaction-motion-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); +vi.mock('./subscription-callbacks/use-dj-queue-changed-callback.hook', () => ({ + __esModule: true, + default: vi.fn(), +})); + +import { renderHook } from '@testing-library/react'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { warnLog } from '@/shared/lib/functions/log/logger'; +import useHandleSubscriptionEvent from './handle-subscription-event'; +import useChatCallback from './subscription-callbacks/use-chat-callback.hook'; +import useCrewEnteredCallback from './subscription-callbacks/use-crew-entered-callback.hook'; +import useCrewExitedCallback from './subscription-callbacks/use-crew-exited-callback.hook'; +import useCrewGradeCallback from './subscription-callbacks/use-crew-grade-callback.hook'; +import useCrewPenaltyCallback from './subscription-callbacks/use-crew-penalty-callback.hook'; +import useCrewProfileCallback from './subscription-callbacks/use-crew-profile-callback.hook'; +import useDjQueueChangedCallback from './subscription-callbacks/use-dj-queue-changed-callback.hook'; +import usePartyroomCloseCallback from './subscription-callbacks/use-partyroom-close-callback.hook'; +import usePartyroomDeactivationCallback from './subscription-callbacks/use-partyroom-deactivation-callback.hook'; +import usePartyroomNoticeCallback from './subscription-callbacks/use-partyroom-notice-callback.hook'; +import usePlaybackStartCallback from './subscription-callbacks/use-playback-start-callback.hook'; +import useReactionAggregationCallback from './subscription-callbacks/use-reaction-aggregation-callback.hook'; +import useReactionMotionCallback from './subscription-callbacks/use-reaction-motion-callback.hook'; + +type EventTypeToHook = { + eventType: PartyroomEventType; + hook: Mock; + label: string; +}; + +const CALLBACK_MAP: EventTypeToHook[] = [ + { + eventType: PartyroomEventType.PARTYROOM_CLOSED, + hook: usePartyroomCloseCallback as Mock, + label: 'usePartyroomCloseCallback', + }, + { + eventType: PartyroomEventType.PLAYBACK_DEACTIVATED, + hook: usePartyroomDeactivationCallback as Mock, + label: 'usePartyroomDeactivationCallback', + }, + { + eventType: PartyroomEventType.CREW_ENTERED, + hook: useCrewEnteredCallback as Mock, + label: 'useCrewEnteredCallback', + }, + { + eventType: PartyroomEventType.CREW_EXITED, + hook: useCrewExitedCallback as Mock, + label: 'useCrewExitedCallback', + }, + { + eventType: PartyroomEventType.PARTYROOM_NOTICE_UPDATED, + hook: usePartyroomNoticeCallback as Mock, + label: 'usePartyroomNoticeCallback', + }, + { + eventType: PartyroomEventType.REACTION_AGGREGATION_UPDATED, + hook: useReactionAggregationCallback as Mock, + label: 'useReactionAggregationCallback', + }, + { + eventType: PartyroomEventType.REACTION_PERFORMED, + hook: useReactionMotionCallback as Mock, + label: 'useReactionMotionCallback', + }, + { + eventType: PartyroomEventType.PLAYBACK_STARTED, + hook: usePlaybackStartCallback as Mock, + label: 'usePlaybackStartCallback', + }, + { + eventType: PartyroomEventType.CHAT_MESSAGE_SENT, + hook: useChatCallback as Mock, + label: 'useChatCallback', + }, + { + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + hook: useCrewGradeCallback as Mock, + label: 'useCrewGradeCallback', + }, + { + eventType: PartyroomEventType.CREW_PENALIZED, + hook: useCrewPenaltyCallback as Mock, + label: 'useCrewPenaltyCallback', + }, + { + eventType: PartyroomEventType.CREW_PROFILE_CHANGED, + hook: useCrewProfileCallback as Mock, + label: 'useCrewProfileCallback', + }, + { + eventType: PartyroomEventType.DJ_QUEUE_CHANGED, + hook: useDjQueueChangedCallback as Mock, + label: 'useDjQueueChangedCallback', + }, +]; + +function createMessage(body: string) { + return { body } as any; +} + +function setupCallbacks() { + const callbacks = new Map(); + + for (const { eventType, hook } of CALLBACK_MAP) { + const cb = vi.fn(); + hook.mockReturnValue(cb); + callbacks.set(eventType, cb); + } + + return callbacks; +} + +describe('useHandleSubscriptionEvent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('유효한 JSON + 알려진 eventType → 해당 콜백이 호출된다', () => { + const callbacks = setupCallbacks(); + const { result } = renderHook(() => useHandleSubscriptionEvent()); + const handler = result.current; + + const event = { eventType: PartyroomEventType.CHAT_MESSAGE_SENT, message: { content: 'hi' } }; + handler(createMessage(JSON.stringify(event))); + + expect(callbacks.get(PartyroomEventType.CHAT_MESSAGE_SENT)).toHaveBeenCalledWith(event); + }); + + test('유효한 JSON + eventType 없음 → 아무 콜백도 호출되지 않는다', () => { + const callbacks = setupCallbacks(); + const { result } = renderHook(() => useHandleSubscriptionEvent()); + const handler = result.current; + + handler(createMessage(JSON.stringify({ data: 'no event type' }))); + + for (const cb of callbacks.values()) { + expect(cb).not.toHaveBeenCalled(); + } + }); + + test('유효한 JSON + 알 수 없는 eventType → warn 로그 + 아무 콜백도 호출되지 않는다', () => { + const callbacks = setupCallbacks(); + const { result } = renderHook(() => useHandleSubscriptionEvent()); + const handler = result.current; + + handler(createMessage(JSON.stringify({ eventType: 'UNKNOWN_EVENT' }))); + + expect(warnLog).toHaveBeenCalled(); + for (const cb of callbacks.values()) { + expect(cb).not.toHaveBeenCalled(); + } + }); + + test('유효하지 않은 JSON → 파싱 실패 warn + 아무 콜백도 호출되지 않는다', () => { + const callbacks = setupCallbacks(); + const { result } = renderHook(() => useHandleSubscriptionEvent()); + const handler = result.current; + + handler(createMessage('not valid json{{{')); + + expect(warnLog).toHaveBeenCalled(); + for (const cb of callbacks.values()) { + expect(cb).not.toHaveBeenCalled(); + } + }); + + describe.each(CALLBACK_MAP.map(({ eventType, label }) => ({ eventType, label })))( + '$eventType', + ({ eventType, label }) => { + test(`${label}이 올바르게 호출된다`, () => { + const callbacks = setupCallbacks(); + const { result } = renderHook(() => useHandleSubscriptionEvent()); + const handler = result.current; + + const event = { eventType }; + handler(createMessage(JSON.stringify(event))); + + expect(callbacks.get(eventType)).toHaveBeenCalledWith(event); + + // 다른 콜백은 호출되지 않아야 한다 + for (const [type, cb] of callbacks.entries()) { + if (type !== eventType) { + expect(cb).not.toHaveBeenCalled(); + } + } + }); + } + ); +}); diff --git a/src/entities/partyroom-client/lib/handle-subscription-event.ts b/src/entities/partyroom-client/lib/handle-subscription-event.ts index 7409e93b..11f09112 100644 --- a/src/entities/partyroom-client/lib/handle-subscription-event.ts +++ b/src/entities/partyroom-client/lib/handle-subscription-event.ts @@ -3,14 +3,15 @@ import { PartyroomEventType, PartyroomSubEvent } from '@/shared/api/websocket/ty import { specificLog, warnLog } from '@/shared/lib/functions/log/logger'; import withDebugger from '@/shared/lib/functions/log/with-debugger'; import useChatCallback from './subscription-callbacks/use-chat-callback.hook'; +import useCrewEnteredCallback from './subscription-callbacks/use-crew-entered-callback.hook'; +import useCrewExitedCallback from './subscription-callbacks/use-crew-exited-callback.hook'; import useCrewGradeCallback from './subscription-callbacks/use-crew-grade-callback.hook'; import useCrewPenaltyCallback from './subscription-callbacks/use-crew-penalty-callback.hook'; import useCrewProfileCallback from './subscription-callbacks/use-crew-profile-callback.hook'; -import usePartyroomAccessCallback from './subscription-callbacks/use-partyroom-access-callback.hook'; +import useDjQueueChangedCallback from './subscription-callbacks/use-dj-queue-changed-callback.hook'; import usePartyroomCloseCallback from './subscription-callbacks/use-partyroom-close-callback.hook'; import usePartyroomDeactivationCallback from './subscription-callbacks/use-partyroom-deactivation-callback.hook'; import usePartyroomNoticeCallback from './subscription-callbacks/use-partyroom-notice-callback.hook'; -import usePlaybackSkipCallback from './subscription-callbacks/use-playback-skip-callback.hook'; import usePlaybackStartCallback from './subscription-callbacks/use-playback-start-callback.hook'; import useReactionAggregationCallback from './subscription-callbacks/use-reaction-aggregation-callback.hook'; import useReactionMotionCallback from './subscription-callbacks/use-reaction-motion-callback.hook'; @@ -22,16 +23,17 @@ const infoLogger = logger(specificLog); export default function useHandleSubscriptionEvent() { const partyroomCloseCallback = usePartyroomCloseCallback(); const partyroomDeactivationCallback = usePartyroomDeactivationCallback(); - const partyroomAccessCallback = usePartyroomAccessCallback(); + const crewEnteredCallback = useCrewEnteredCallback(); + const crewExitedCallback = useCrewExitedCallback(); const partyroomNoticeCallback = usePartyroomNoticeCallback(); const reactionAggregationCallback = useReactionAggregationCallback(); const reactionMotionCallback = useReactionMotionCallback(); const playbackStartCallback = usePlaybackStartCallback(); - const playbackSkipCallback = usePlaybackSkipCallback(); const chatCallback = useChatCallback(); const crewGradeCallback = useCrewGradeCallback(); const crewPenaltyCallback = useCrewPenaltyCallback(); const crewProfileCallback = useCrewProfileCallback(); + const djQueueChangedCallback = useDjQueueChangedCallback(); return (message: IMessage) => { const event = parseMessage(message); @@ -49,42 +51,45 @@ export default function useHandleSubscriptionEvent() { infoLogger('Received event:', event); switch (event.eventType) { - case PartyroomEventType.PARTYROOM_CLOSE: + case PartyroomEventType.PARTYROOM_CLOSED: partyroomCloseCallback(event); break; - case PartyroomEventType.PARTYROOM_DEACTIVATION: + case PartyroomEventType.PLAYBACK_DEACTIVATED: partyroomDeactivationCallback(event); break; - case PartyroomEventType.PARTYROOM_ACCESS: - partyroomAccessCallback(event); + case PartyroomEventType.CREW_ENTERED: + crewEnteredCallback(event); break; - case PartyroomEventType.PARTYROOM_NOTICE: + case PartyroomEventType.CREW_EXITED: + crewExitedCallback(event); + break; + case PartyroomEventType.PARTYROOM_NOTICE_UPDATED: partyroomNoticeCallback(event); break; - case PartyroomEventType.REACTION_AGGREGATION: + case PartyroomEventType.REACTION_AGGREGATION_UPDATED: reactionAggregationCallback(event); break; - case PartyroomEventType.REACTION_MOTION: + case PartyroomEventType.REACTION_PERFORMED: reactionMotionCallback(event); break; - case PartyroomEventType.PLAYBACK_START: + case PartyroomEventType.PLAYBACK_STARTED: playbackStartCallback(event); break; - case PartyroomEventType.PLAYBACK_SKIP: - playbackSkipCallback(event); - break; - case PartyroomEventType.CHAT: + case PartyroomEventType.CHAT_MESSAGE_SENT: chatCallback(event); break; - case PartyroomEventType.CREW_GRADE: + case PartyroomEventType.CREW_GRADE_CHANGED: crewGradeCallback(event); break; - case PartyroomEventType.CREW_PENALTY: + case PartyroomEventType.CREW_PENALIZED: crewPenaltyCallback(event); break; - case PartyroomEventType.CREW_PROFILE: + case PartyroomEventType.CREW_PROFILE_CHANGED: crewProfileCallback(event); break; + case PartyroomEventType.DJ_QUEUE_CHANGED: + djQueueChangedCallback(event); + break; } }; } diff --git a/src/entities/partyroom-client/lib/partyroom-client.test.ts b/src/entities/partyroom-client/lib/partyroom-client.test.ts new file mode 100644 index 00000000..9e876b19 --- /dev/null +++ b/src/entities/partyroom-client/lib/partyroom-client.test.ts @@ -0,0 +1,81 @@ +vi.mock('@/shared/api/websocket/client'); + +import SocketClient from '@/shared/api/websocket/client'; +import PartyroomClient from './partyroom-client'; + +const MockSocketClient = SocketClient as MockedClass; + +describe('PartyroomClient', () => { + let client: PartyroomClient; + let mockSocketInstance: Mocked; + + beforeEach(() => { + vi.clearAllMocks(); + client = new PartyroomClient(); + mockSocketInstance = MockSocketClient.mock.instances[0] as Mocked; + }); + + test('connect() → socketClient.connect()를 위임한다', () => { + client.connect(); + expect(mockSocketInstance.connect).toHaveBeenCalled(); + }); + + test('connected → socketClient.connected를 위임한다', () => { + Object.defineProperty(mockSocketInstance, 'connected', { get: () => true }); + expect(client.connected).toBe(true); + }); + + test('onConnect → socketClient.onConnect를 위임한다', () => { + const callback = vi.fn(); + const options = { once: true }; + client.onConnect(callback, options); + expect(mockSocketInstance.onConnect).toHaveBeenCalledWith(callback, options); + }); + + describe('subscribe', () => { + test('이미 구독이 있으면 Error를 throw한다', () => { + mockSocketInstance.subscriptions = [{ destination: '/sub/partyrooms/1' } as any]; + + expect(() => client.subscribe(2, vi.fn())).toThrow( + 'Cannot connect to multiple partyrooms at the same time.' + ); + }); + + test('구독이 없으면 올바른 경로로 subscribe를 호출한다', () => { + mockSocketInstance.subscriptions = []; + const handler = vi.fn(); + + client.subscribe(42, handler); + + expect(mockSocketInstance.subscribe).toHaveBeenCalledWith('/sub/partyrooms/42', handler); + }); + }); + + test('unsubscribeCurrentRoom → 올바른 경로로 unsubscribe를 호출한다', () => { + mockSocketInstance.subscriptions = []; + client.subscribe(10, vi.fn()); + + client.unsubscribeCurrentRoom(); + + expect(mockSocketInstance.unsubscribe).toHaveBeenCalledWith('/sub/partyrooms/10'); + }); + + describe('sendChatMessage', () => { + test('구독 전에 호출하면 Error를 throw한다', () => { + expect(() => client.sendChatMessage('hello')).toThrow( + 'Cannot send chat message without subscribing to a partyroom.' + ); + }); + + test('구독 후에 호출하면 올바른 경로와 내용으로 send를 호출한다', () => { + mockSocketInstance.subscriptions = []; + client.subscribe(7, vi.fn()); + + client.sendChatMessage('hi there'); + + expect(mockSocketInstance.send).toHaveBeenCalledWith('/pub/groups/7/send', { + content: 'hi there', + }); + }); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.test.ts new file mode 100644 index 00000000..69382ac2 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.test.ts @@ -0,0 +1,82 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/functions/log/logger', () => ({ + warnLog: vi.fn(), +})); +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + default: () => (logFn: Function) => logFn, +})); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { warnLog } from '@/shared/lib/functions/log/logger'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useChatCallback from './use-chat-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('useChatCallback', () => { + test('크루를 찾아서 user 채팅 메시지를 append한다', () => { + const crew = createCrew({ crewId: 5, nickname: '채팅유저' }); + store.getState().updateCrews(() => [crew]); + + const { result } = renderHook(() => useChatCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CHAT_MESSAGE_SENT, + crew: { crewId: 5 }, + message: { messageId: 'msg-1', content: '안녕하세요' }, + }); + + const messages = store.getState().chat.getMessages(); + expect(messages).toHaveLength(1); + expect(messages[0].from).toBe('user'); + if (messages[0].from === 'user') { + expect(messages[0].crew).toEqual(crew); + expect(messages[0].message).toEqual({ messageId: 'msg-1', content: '안녕하세요' }); + } + }); + + test('크루를 찾지 못하면 warn 로그 + 메시지 append하지 않음', () => { + store.getState().updateCrews(() => [createCrew({ crewId: 1 })]); + + const { result } = renderHook(() => useChatCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CHAT_MESSAGE_SENT, + crew: { crewId: 999 }, + message: { messageId: 'msg-2', content: '메시지' }, + }); + + expect(warnLog).toHaveBeenCalled(); + expect(store.getState().chat.getMessages()).toHaveLength(0); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.ts index 9b2f9a6b..34b87e8f 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-chat-callback.hook.ts @@ -1,5 +1,5 @@ import * as Crew from '@/entities/current-partyroom/model/crew.model'; -import { ChatEvent } from '@/shared/api/websocket/types/partyroom'; +import { ChatMessageSentEvent } from '@/shared/api/websocket/types/partyroom'; import { warnLog } from '@/shared/lib/functions/log/logger'; import withDebugger from '@/shared/lib/functions/log/with-debugger'; import { useStores } from '@/shared/lib/store/stores.context'; @@ -8,7 +8,7 @@ export default function useChatCallback() { const { useCurrentPartyroom } = useStores(); const appendChatMessage = useCurrentPartyroom((state) => state.appendChatMessage); - return (event: ChatEvent) => { + return (event: ChatMessageSentEvent) => { const { crews } = useCurrentPartyroom.getState(); const crew = crews.find((crew) => crew.crewId === event.crew.crewId); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-entered-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-entered-callback.hook.ts new file mode 100644 index 00000000..4a9d4704 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-entered-callback.hook.ts @@ -0,0 +1,32 @@ +import { MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomCrew } from '@/shared/api/http/types/partyrooms'; +import { CrewEnteredEvent } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; + +export default function useCrewEnteredCallback() { + const { useCurrentPartyroom } = useStores(); + const updateCrews = useCurrentPartyroom((state) => state.updateCrews); + + return (event: CrewEnteredEvent) => { + const crew = flattenCrewFromEvent(event.crew); + updateCrews((prev) => [...prev, { ...crew, motionType: MotionType.NONE }]); + }; +} + +/** 이벤트의 중첩 아바타 구조를 스토어의 플랫 PartyroomCrew 구조로 변환 */ +function flattenCrewFromEvent(eventCrew: CrewEnteredEvent['crew']): PartyroomCrew { + return { + crewId: eventCrew.crewId, + gradeType: eventCrew.gradeType, + nickname: eventCrew.nickname, + avatarCompositionType: eventCrew.avatar.avatarCompositionType, + avatarBodyUri: eventCrew.avatar.avatarBodyUri, + avatarFaceUri: eventCrew.avatar.avatarFaceUri ?? '', + avatarIconUri: eventCrew.avatar.avatarIconUri, + combinePositionX: eventCrew.avatar.combinePositionX, + combinePositionY: eventCrew.avatar.combinePositionY, + offsetX: eventCrew.avatar.offsetX, + offsetY: eventCrew.avatar.offsetY, + scale: eventCrew.avatar.scale, + }; +} diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-exited-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-exited-callback.hook.ts new file mode 100644 index 00000000..bf817ed7 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-exited-callback.hook.ts @@ -0,0 +1,11 @@ +import { CrewExitedEvent } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; + +export default function useCrewExitedCallback() { + const { useCurrentPartyroom } = useStores(); + const updateCrews = useCurrentPartyroom((state) => state.updateCrews); + + return (event: CrewExitedEvent) => { + updateCrews((prev) => prev.filter((crew) => crew.crewId !== event.crewId)); + }; +} diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.test.ts new file mode 100644 index 00000000..e3578be5 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.test.ts @@ -0,0 +1,189 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/functions/log/logger', () => ({ + errorLog: vi.fn(), +})); +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + default: () => (logFn: Function) => logFn, +})); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { errorLog } from '@/shared/lib/functions/log/logger'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCrewGradeCallback from './use-crew-grade-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('useCrewGradeCallback', () => { + test('대상 크루의 gradeType을 업데이트한다', () => { + store + .getState() + .updateCrews(() => [ + createCrew({ crewId: 1, nickname: '관리자', gradeType: GradeType.HOST }), + createCrew({ crewId: 2, nickname: '대상유저', gradeType: GradeType.CLUBBER }), + ]); + + const { result } = renderHook(() => useCrewGradeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + adjuster: { crewId: 1 }, + adjusted: { + crewId: 2, + prevGradeType: GradeType.CLUBBER, + currGradeType: GradeType.MODERATOR, + }, + }); + + const crews = store.getState().crews; + expect(crews[1].gradeType).toBe(GradeType.MODERATOR); + // adjuster의 등급은 변경되지 않음 + expect(crews[0].gradeType).toBe(GradeType.HOST); + }); + + test('등급 변경 시스템 채팅 메시지를 append한다', () => { + store + .getState() + .updateCrews(() => [ + createCrew({ crewId: 1, nickname: '관리자' }), + createCrew({ crewId: 2, nickname: '대상유저' }), + ]); + + const { result } = renderHook(() => useCrewGradeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + adjuster: { crewId: 1 }, + adjusted: { + crewId: 2, + prevGradeType: GradeType.CLUBBER, + currGradeType: GradeType.MODERATOR, + }, + }); + + const messages = store.getState().chat.getMessages(); + expect(messages).toHaveLength(1); + expect(messages[0].from).toBe('system'); + if (messages[0].from === 'system') { + expect(messages[0].content).toContain('관리자'); + expect(messages[0].content).toContain('대상유저'); + expect(messages[0].content).toContain('Mod'); + } + }); + + test('조정 대상이 본인이면 me.gradeType도 업데이트 + alert.notify 호출', () => { + store.getState().init({ + id: 1, + me: { crewId: 2, gradeType: GradeType.CLUBBER }, + playbackActivated: false, + crews: [ + createCrew({ crewId: 1, nickname: '관리자' }), + createCrew({ crewId: 2, nickname: '본인' }), + ], + notice: '', + }); + + const alertNotify = vi.spyOn(store.getState().alert, 'notify'); + + const { result } = renderHook(() => useCrewGradeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + adjuster: { crewId: 1 }, + adjusted: { + crewId: 2, + prevGradeType: GradeType.CLUBBER, + currGradeType: GradeType.MODERATOR, + }, + }); + + expect(store.getState().me?.gradeType).toBe(GradeType.MODERATOR); + expect(alertNotify).toHaveBeenCalledWith({ + type: 'grade-adjusted', + prev: GradeType.CLUBBER, + next: GradeType.MODERATOR, + }); + }); + + test('조정 대상이 본인이 아니면 me 업데이트/alert 호출 안 함', () => { + store.getState().init({ + id: 1, + me: { crewId: 3, gradeType: GradeType.CLUBBER }, + playbackActivated: false, + crews: [ + createCrew({ crewId: 1, nickname: '관리자' }), + createCrew({ crewId: 2, nickname: '대상유저' }), + createCrew({ crewId: 3, nickname: '본인' }), + ], + notice: '', + }); + + const alertNotify = vi.spyOn(store.getState().alert, 'notify'); + + const { result } = renderHook(() => useCrewGradeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + adjuster: { crewId: 1 }, + adjusted: { + crewId: 2, + prevGradeType: GradeType.CLUBBER, + currGradeType: GradeType.MODERATOR, + }, + }); + + expect(store.getState().me?.gradeType).toBe(GradeType.CLUBBER); + expect(alertNotify).not.toHaveBeenCalled(); + }); + + test('adjuster/adjusted를 찾지 못하면 에러 로그 + 아무 동작 안 함', () => { + store.getState().updateCrews(() => [createCrew({ crewId: 1 })]); + + const { result } = renderHook(() => useCrewGradeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_GRADE_CHANGED, + adjuster: { crewId: 999 }, + adjusted: { + crewId: 888, + prevGradeType: GradeType.CLUBBER, + currGradeType: GradeType.MODERATOR, + }, + }); + + expect(errorLog).toHaveBeenCalledWith('Adjuster or Adjusted not found'); + expect(store.getState().chat.getMessages()).toHaveLength(0); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.ts index 80a5fa2f..a908cf38 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-grade-callback.hook.ts @@ -1,4 +1,4 @@ -import { CrewGradeEvent } from '@/shared/api/websocket/types/partyroom'; +import { CrewGradeChangedEvent } from '@/shared/api/websocket/types/partyroom'; import { errorLog } from '@/shared/lib/functions/log/logger'; import withDebugger from '@/shared/lib/functions/log/with-debugger'; import { useStores } from '@/shared/lib/store/stores.context'; @@ -17,7 +17,7 @@ export default function useCrewGradeCallback() { ]); // TODO: “대상자에게 모달 알림“ 기능 구현 - return (event: CrewGradeEvent) => { + return (event: CrewGradeChangedEvent) => { const crews = useCurrentPartyroom.getState().crews; const adjuster = crews.find((crew) => crew.crewId === event.adjuster.crewId); const adjusted = crews.find((crew) => crew.crewId === event.adjusted.crewId); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.test.ts new file mode 100644 index 00000000..e553b419 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.test.ts @@ -0,0 +1,167 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/functions/log/logger', () => ({ + errorLog: vi.fn(), +})); +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + default: () => (logFn: Function) => logFn, +})); +vi.mock('@/shared/lib/localization/i18n.context', () => ({ + useI18n: () => ({ + chat: { para: { remove_chat: '{subject}님이 {target}님의 채팅을 삭제했습니다.' } }, + }), +})); +vi.mock('@/shared/lib/localization/renderer/processors/variable-processor-util', () => ({ + processI18nString: (template: string, vars: Record) => + template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? ''), +})); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType, PenaltyType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { errorLog } from '@/shared/lib/functions/log/logger'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCrewPenaltyCallback from './use-crew-penalty-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('useCrewPenaltyCallback', () => { + describe('CHAT_MESSAGE_REMOVAL', () => { + test('해당 messageId의 채팅을 시스템 메시지로 교체한다', () => { + const punisher = createCrew({ crewId: 1, nickname: '관리자' }); + const punished = createCrew({ crewId: 2, nickname: '위반자' }); + store.getState().updateCrews(() => [punisher, punished]); + + // user 채팅 메시지 추가 + store.getState().appendChatMessage({ + from: 'user', + crew: punished, + message: { messageId: 'target-msg', content: '위반 메시지' }, + receivedAt: 1000, + }); + + const { result } = renderHook(() => useCrewPenaltyCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PENALIZED, + penaltyType: PenaltyType.CHAT_MESSAGE_REMOVAL, + detail: 'target-msg', + punisher: { crewId: 1 }, + punished: { crewId: 2 }, + }); + + const messages = store.getState().chat.getMessages(); + expect(messages).toHaveLength(1); + expect(messages[0].from).toBe('system'); + if (messages[0].from === 'system') { + expect(messages[0].content).toContain('관리자'); + expect(messages[0].content).toContain('위반자'); + } + }); + + test('punisher/punished 못 찾으면 에러 로그 + 아무 동작 안 함', () => { + store.getState().updateCrews(() => [createCrew({ crewId: 1 })]); + + store.getState().appendChatMessage({ + from: 'user', + crew: createCrew({ crewId: 99 }), + message: { messageId: 'some-msg', content: '내용' }, + receivedAt: 1000, + }); + + const { result } = renderHook(() => useCrewPenaltyCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PENALIZED, + penaltyType: PenaltyType.CHAT_MESSAGE_REMOVAL, + detail: 'some-msg', + punisher: { crewId: 999 }, + punished: { crewId: 888 }, + }); + + expect(errorLog).toHaveBeenCalledWith('Punisher or Punished not found'); + }); + }); + + describe('기타 패널티', () => { + test('본인이 대상 → alert.notify 호출', () => { + store.getState().init({ + id: 1, + me: { crewId: 2, gradeType: GradeType.CLUBBER }, + playbackActivated: false, + crews: [createCrew({ crewId: 1 }), createCrew({ crewId: 2 })], + notice: '', + }); + + const alertNotify = vi.spyOn(store.getState().alert, 'notify'); + + const { result } = renderHook(() => useCrewPenaltyCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PENALIZED, + penaltyType: PenaltyType.CHAT_BAN_30_SECONDS, + detail: '도배 행위', + punisher: { crewId: 1 }, + punished: { crewId: 2 }, + }); + + expect(alertNotify).toHaveBeenCalledWith({ + type: PenaltyType.CHAT_BAN_30_SECONDS, + reason: '도배 행위', + }); + }); + + test('본인이 아님 → alert 호출 안 함', () => { + store.getState().init({ + id: 1, + me: { crewId: 3, gradeType: GradeType.CLUBBER }, + playbackActivated: false, + crews: [createCrew({ crewId: 1 }), createCrew({ crewId: 2 }), createCrew({ crewId: 3 })], + notice: '', + }); + + const alertNotify = vi.spyOn(store.getState().alert, 'notify'); + + const { result } = renderHook(() => useCrewPenaltyCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PENALIZED, + penaltyType: PenaltyType.ONE_TIME_EXPULSION, + detail: '부적절한 행위', + punisher: { crewId: 1 }, + punished: { crewId: 2 }, + }); + + expect(alertNotify).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.ts index 64d724e5..48c4b43d 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-penalty-callback.hook.ts @@ -1,5 +1,5 @@ import { PenaltyType } from '@/shared/api/http/types/@enums'; -import { CrewPenaltyEvent } from '@/shared/api/websocket/types/partyroom'; +import { CrewPenalizedEvent } from '@/shared/api/websocket/types/partyroom'; import { errorLog } from '@/shared/lib/functions/log/logger'; import withDebugger from '@/shared/lib/functions/log/with-debugger'; import { useI18n } from '@/shared/lib/localization/i18n.context'; @@ -20,7 +20,7 @@ export default function useCrewPenaltyCallback() { ]); const t = useI18n(); - return (event: CrewPenaltyEvent) => { + return (event: CrewPenalizedEvent) => { if (event.penaltyType === PenaltyType.CHAT_MESSAGE_REMOVAL) { const crews = useCurrentPartyroom.getState().crews; diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.test.ts new file mode 100644 index 00000000..b5905701 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.test.ts @@ -0,0 +1,105 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCrewProfileCallback from './use-crew-profile-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('useCrewProfileCallback', () => { + test('해당 crewId의 프로필 필드를 업데이트한다 (eventType 제외)', () => { + store.getState().updateCrews(() => [createCrew({ crewId: 1, nickname: '원래이름' })]); + + const { result } = renderHook(() => useCrewProfileCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PROFILE_CHANGED, + crewId: 1, + nickname: '새이름', + avatar: { + avatarCompositionType: 'COMBINED' as any, + avatarBodyUri: 'new-body.png', + avatarFaceUri: 'new-face.png', + avatarIconUri: 'new-icon.png', + combinePositionX: 10, + combinePositionY: 20, + offsetX: 5, + offsetY: 5, + scale: 2, + }, + }); + + const crew = store.getState().crews[0]; + expect(crew.nickname).toBe('새이름'); + expect(crew.avatarBodyUri).toBe('new-body.png'); + expect(crew.avatarFaceUri).toBe('new-face.png'); + expect(crew.avatarIconUri).toBe('new-icon.png'); + expect(crew.combinePositionX).toBe(10); + expect(crew.combinePositionY).toBe(20); + expect(crew.offsetX).toBe(5); + expect(crew.offsetY).toBe(5); + expect(crew.scale).toBe(2); + // motionType은 기존 값 유지 + expect(crew.motionType).toBe(MotionType.NONE); + }); + + test('다른 크루는 변경되지 않는다', () => { + store + .getState() + .updateCrews(() => [ + createCrew({ crewId: 1, nickname: '유저1' }), + createCrew({ crewId: 2, nickname: '유저2' }), + ]); + + const { result } = renderHook(() => useCrewProfileCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.CREW_PROFILE_CHANGED, + crewId: 1, + nickname: '변경된이름', + avatar: { + avatarCompositionType: 'COMBINED' as any, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + }, + }); + + const crews = store.getState().crews; + expect(crews[0].nickname).toBe('변경된이름'); + expect(crews[1].nickname).toBe('유저2'); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.ts index 5c1fc19c..12ce0bc2 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-crew-profile-callback.hook.ts @@ -1,17 +1,21 @@ -import { CrewProfileEvent } from '@/shared/api/websocket/types/partyroom'; -import { omit } from '@/shared/lib/functions/omit'; +import { CrewProfileChangedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; export default function useCrewProfileCallback() { const updateCrews = useStores().useCurrentPartyroom((state) => state.updateCrews); - return (event: CrewProfileEvent) => { + return (event: CrewProfileChangedEvent) => { updateCrews((prev) => { const updatedCrews = prev.map((crew) => { if (crew.crewId !== event.crewId) { return crew; } - const crewUpdated = { ...crew, ...omit(event, 'eventType') }; + const crewUpdated = { + ...crew, + nickname: event.nickname, + ...event.avatar, + avatarFaceUri: event.avatar.avatarFaceUri ?? '', + }; return crewUpdated; }); return updatedCrews; diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-dj-queue-changed-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-dj-queue-changed-callback.hook.ts new file mode 100644 index 00000000..1a31c01e --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-dj-queue-changed-callback.hook.ts @@ -0,0 +1,17 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { DjingQueue } from '@/shared/api/http/types/partyrooms'; +import { DjQueueChangedEvent } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; + +export default function useDjQueueChangedCallback() { + const queryClient = useQueryClient(); + const { useCurrentPartyroom } = useStores(); + const partyroomId = useCurrentPartyroom((state) => state.id); + + return (event: DjQueueChangedEvent) => { + queryClient.setQueryData([QueryKeys.DjingQueue, partyroomId], (prev) => + prev ? { ...prev, djs: event.djs } : prev + ); + }; +} diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-access-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-access-callback.hook.ts deleted file mode 100644 index abd550e9..00000000 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-access-callback.hook.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AccessType, MotionType } from '@/shared/api/http/types/@enums'; -import { PartyroomAccessEvent } from '@/shared/api/websocket/types/partyroom'; -import { useStores } from '@/shared/lib/store/stores.context'; - -export default function usePartyroomAccessCallback() { - const { useCurrentPartyroom } = useStores(); - const updateCrews = useCurrentPartyroom((state) => state.updateCrews); - - return (event: PartyroomAccessEvent) => { - switch (event.accessType) { - case AccessType.ENTER: - updateCrews((prev) => { - return [ - ...prev, - { - ...event.crew, - motionType: MotionType.NONE, - }, - ]; - }); - break; - case AccessType.EXIT: - updateCrews((prev) => { - return prev.filter((crew) => crew.crewId !== event.crew.crewId); - }); - break; - } - }; -} diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.test.ts new file mode 100644 index 00000000..9f897444 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.test.ts @@ -0,0 +1,107 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/entities/current-partyroom', () => ({ + useRemoveCurrentPartyroomCaches: vi.fn(), +})); +vi.mock('@/shared/lib/router/use-app-router.hook', () => ({ + useAppRouter: vi.fn(), +})); +vi.mock('@/shared/ui/components/dialog', () => ({ + useDialog: vi.fn(), +})); +vi.mock('@/shared/lib/localization/i18n.context', () => ({ + useI18n: () => ({ + party: { para: { closed: '파티룸이 닫혔습니다.' } }, + }), +})); + +import { renderHook } from '@testing-library/react'; +import { useRemoveCurrentPartyroomCaches } from '@/entities/current-partyroom'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useAppRouter } from '@/shared/lib/router/use-app-router.hook'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import usePartyroomCloseCallback from './use-partyroom-close-callback.hook'; + +let store: ReturnType; +const mockReplace = vi.fn(); +const mockRemoveCaches = vi.fn(); +const mockOpenAlertDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); + (useAppRouter as Mock).mockReturnValue({ replace: mockReplace }); + (useRemoveCurrentPartyroomCaches as Mock).mockReturnValue(mockRemoveCaches); + (useDialog as Mock).mockReturnValue({ openAlertDialog: mockOpenAlertDialog }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('usePartyroomCloseCallback', () => { + test("router.replace('/parties') 호출", () => { + const { result } = renderHook(() => usePartyroomCloseCallback()); + const callback = result.current; + + callback({ eventType: PartyroomEventType.PARTYROOM_CLOSED }); + + expect(mockReplace).toHaveBeenCalledWith('/parties'); + }); + + test('reset 호출 → 상태 초기화', () => { + store.getState().init({ + id: 99, + me: { crewId: 1, gradeType: GradeType.HOST }, + playbackActivated: true, + crews: [createCrew()], + notice: '공지', + }); + + const { result } = renderHook(() => usePartyroomCloseCallback()); + const callback = result.current; + + callback({ eventType: PartyroomEventType.PARTYROOM_CLOSED }); + + const state = store.getState(); + expect(state.id).toBeUndefined(); + expect(state.playbackActivated).toBe(false); + expect(state.crews).toEqual([]); + }); + + test('removeCurrentPartyroomCaches 호출', () => { + const { result } = renderHook(() => usePartyroomCloseCallback()); + const callback = result.current; + + callback({ eventType: PartyroomEventType.PARTYROOM_CLOSED }); + + expect(mockRemoveCaches).toHaveBeenCalledTimes(1); + }); + + test('openAlertDialog 호출 (t.party.para.closed 내용)', () => { + const { result } = renderHook(() => usePartyroomCloseCallback()); + const callback = result.current; + + callback({ eventType: PartyroomEventType.PARTYROOM_CLOSED }); + + expect(mockOpenAlertDialog).toHaveBeenCalledWith({ + content: '파티룸이 닫혔습니다.', + }); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.ts index 27a0696a..0a42fe47 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-close-callback.hook.ts @@ -1,5 +1,5 @@ import { useRemoveCurrentPartyroomCaches } from '@/entities/current-partyroom'; -import { PartyroomCloseEvent } from '@/shared/api/websocket/types/partyroom'; +import { PartyroomClosedEvent } from '@/shared/api/websocket/types/partyroom'; import { useI18n } from '@/shared/lib/localization/i18n.context'; import { useAppRouter } from '@/shared/lib/router/use-app-router.hook'; import { useStores } from '@/shared/lib/store/stores.context'; @@ -13,7 +13,7 @@ export default function usePartyroomCloseCallback() { const { openAlertDialog } = useDialog(); const t = useI18n(); - return (_event: PartyroomCloseEvent) => { + return (_event: PartyroomClosedEvent) => { router.replace('/parties'); reset(); removeCurrentPartyroomCaches(); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.test.ts new file mode 100644 index 00000000..c3503b35 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.test.ts @@ -0,0 +1,58 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import usePartyroomDeactivationCallback from './use-partyroom-deactivation-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('usePartyroomDeactivationCallback', () => { + test('reset 호출 → 상태 초기화됨', () => { + // 상태를 변경해둠 + store.getState().init({ + id: 99, + me: { crewId: 1, gradeType: GradeType.HOST }, + playbackActivated: true, + crews: [createCrew()], + notice: '공지사항', + }); + + const { result } = renderHook(() => usePartyroomDeactivationCallback()); + const callback = result.current; + + callback({ eventType: PartyroomEventType.PLAYBACK_DEACTIVATED }); + + const state = store.getState(); + expect(state.id).toBeUndefined(); + expect(state.me).toBeUndefined(); + expect(state.playbackActivated).toBe(false); + expect(state.crews).toEqual([]); + expect(state.notice).toBe(''); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.ts index fa212ed7..93ec47a1 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-deactivation-callback.hook.ts @@ -1,14 +1,11 @@ -import { PartyroomDeactivationEvent } from '@/shared/api/websocket/types/partyroom'; +import { PlaybackDeactivatedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; -import useInvalidateDjingQueue from './utils/use-invalidate-djing-queue.hook'; export default function usePartyroomDeactivationCallback() { const { useCurrentPartyroom } = useStores(); const reset = useCurrentPartyroom((state) => state.reset); - const invalidateDjingQueue = useInvalidateDjingQueue(); - return (_event: PartyroomDeactivationEvent) => { + return (_event: PlaybackDeactivatedEvent) => { reset(); - invalidateDjingQueue(); }; } diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.test.ts new file mode 100644 index 00000000..44ae1986 --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.test.ts @@ -0,0 +1,44 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import usePartyroomNoticeCallback from './use-partyroom-notice-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('usePartyroomNoticeCallback', () => { + test('notice를 event.content로 업데이트한다', () => { + const { result } = renderHook(() => usePartyroomNoticeCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.PARTYROOM_NOTICE_UPDATED, + content: '새로운 공지사항입니다', + }); + + expect(store.getState().notice).toBe('새로운 공지사항입니다'); + }); + + test('빈 문자열로도 업데이트할 수 있다', () => { + const { result } = renderHook(() => usePartyroomNoticeCallback()); + const callback = result.current; + + // 먼저 공지 설정 + store.getState().updateNotice('기존 공지'); + + callback({ + eventType: PartyroomEventType.PARTYROOM_NOTICE_UPDATED, + content: '', + }); + + expect(store.getState().notice).toBe(''); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.ts index e5c503c8..b1ac4b4d 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-partyroom-notice-callback.hook.ts @@ -1,11 +1,11 @@ -import { PartyroomNoticeEvent } from '@/shared/api/websocket/types/partyroom'; +import { PartyroomNoticeUpdatedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; export default function usePartyroomNoticeCallback() { const { useCurrentPartyroom } = useStores(); const updateNotice = useCurrentPartyroom((state) => state.updateNotice); - return (event: PartyroomNoticeEvent) => { + return (event: PartyroomNoticeUpdatedEvent) => { updateNotice(event.content); }; } diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-skip-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-skip-callback.hook.ts deleted file mode 100644 index 8bd1c0aa..00000000 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-skip-callback.hook.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PlaybackSkipEvent } from '@/shared/api/websocket/types/partyroom'; - -export default function usePlaybackSkipCallback() { - return (_event: PlaybackSkipEvent) => { - // TODO: implementation - }; -} diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.test.ts new file mode 100644 index 00000000..39d654bd --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.test.ts @@ -0,0 +1,96 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import usePlaybackStartCallback from './use-playback-start-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +const createPlaybackEvent = () => ({ + eventType: PartyroomEventType.PLAYBACK_STARTED as const, + crewId: 10, + playback: { + linkId: 'yt-abc123', + name: '테스트 곡', + duration: '03:45', + thumbnailImage: 'thumb.jpg', + }, +}); + +describe('usePlaybackStartCallback', () => { + test('playbackActivated=true, playback 업데이트, currentDj 설정', () => { + const { result } = renderHook(() => usePlaybackStartCallback()); + const callback = result.current; + const event = createPlaybackEvent(); + + callback(event); + + const state = store.getState(); + expect(state.playbackActivated).toBe(true); + expect(state.currentDj).toEqual({ crewId: 10 }); + }); + + test('reaction 리셋 후 aggregation 설정', () => { + // 기존 reaction history 설정 + store.getState().updateReaction({ + history: { isLiked: true, isDisliked: false, isGrabbed: false }, + }); + + const { result } = renderHook(() => usePlaybackStartCallback()); + const callback = result.current; + const event = createPlaybackEvent(); + + callback(event); + + const reaction = store.getState().reaction; + expect(reaction.aggregation).toEqual({ + likeCount: 0, + dislikeCount: 0, + grabCount: 0, + }); + }); + + test('모든 크루 motionType NONE으로 리셋', () => { + store + .getState() + .updateCrews(() => [ + createCrew({ crewId: 1, motionType: MotionType.DANCE_TYPE_1 }), + createCrew({ crewId: 2, motionType: MotionType.DANCE_TYPE_2 }), + ]); + + const { result } = renderHook(() => usePlaybackStartCallback()); + const callback = result.current; + + callback(createPlaybackEvent()); + + const crews = store.getState().crews; + expect(crews[0].motionType).toBe(MotionType.NONE); + expect(crews[1].motionType).toBe(MotionType.NONE); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.ts index dfcd3c0e..d7d1d78b 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-playback-start-callback.hook.ts @@ -1,6 +1,5 @@ -import { PlaybackStartEvent } from '@/shared/api/websocket/types/partyroom'; +import { PlaybackStartedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; -import useInvalidateDjingQueue from './utils/use-invalidate-djing-queue.hook'; export default function usePlaybackStartCallback() { const { useCurrentPartyroom } = useStores(); @@ -19,9 +18,8 @@ export default function usePlaybackStartCallback() { state.updateReaction, state.resetCrewsMotion, ]); - const invalidateDjingQueue = useInvalidateDjingQueue(); - return (event: PlaybackStartEvent) => { + return (event: PlaybackStartedEvent) => { updatePlaybackActivated(true); updatePlayback(event.playback); updateCurrentDj({ crewId: event.crewId }); @@ -29,12 +27,11 @@ export default function usePlaybackStartCallback() { updateReaction((prev) => ({ ...prev, aggregation: { - likeCount: event.playback.likeCount, - dislikeCount: event.playback.dislikeCount, - grabCount: event.playback.grabCount, + likeCount: 0, + dislikeCount: 0, + grabCount: 0, }, })); resetCrewsMotion(); - invalidateDjingQueue(); }; } diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.test.ts new file mode 100644 index 00000000..ad673a1c --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.test.ts @@ -0,0 +1,61 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useReactionAggregationCallback from './use-reaction-aggregation-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useReactionAggregationCallback', () => { + test('aggregation 값을 이벤트 데이터로 교체한다', () => { + const { result } = renderHook(() => useReactionAggregationCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.REACTION_AGGREGATION_UPDATED, + aggregation: { likeCount: 10, dislikeCount: 3, grabCount: 5 }, + }); + + const reaction = store.getState().reaction; + expect(reaction.aggregation).toEqual({ + likeCount: 10, + dislikeCount: 3, + grabCount: 5, + }); + }); + + test('history는 변경되지 않는다', () => { + // history를 먼저 변경 + store.getState().updateReaction({ + history: { isLiked: true, isDisliked: false, isGrabbed: true }, + }); + + const { result } = renderHook(() => useReactionAggregationCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.REACTION_AGGREGATION_UPDATED, + aggregation: { likeCount: 99, dislikeCount: 0, grabCount: 0 }, + }); + + const reaction = store.getState().reaction; + expect(reaction.history).toEqual({ + isLiked: true, + isDisliked: false, + isGrabbed: true, + }); + expect(reaction.aggregation).toEqual({ + likeCount: 99, + dislikeCount: 0, + grabCount: 0, + }); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.ts index 267a95c4..6aff4ef9 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-aggregation-callback.hook.ts @@ -1,11 +1,11 @@ -import { ReactionAggregationEvent } from '@/shared/api/websocket/types/partyroom'; +import { ReactionAggregationUpdatedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; export default function useReactionAggregationCallback() { const { useCurrentPartyroom } = useStores(); const updateReaction = useCurrentPartyroom((state) => state.updateReaction); - return (event: ReactionAggregationEvent) => { + return (event: ReactionAggregationUpdatedEvent) => { updateReaction((prev) => ({ ...prev, aggregation: event.aggregation, diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.test.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.test.ts new file mode 100644 index 00000000..4c2f121f --- /dev/null +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.test.ts @@ -0,0 +1,76 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import type * as Crew from '@/entities/current-partyroom/model/crew.model'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType, ReactionType } from '@/shared/api/http/types/@enums'; +import { PartyroomEventType } from '@/shared/api/websocket/types/partyroom'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useReactionMotionCallback from './use-reaction-motion-callback.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (overrides: Partial = {}): Crew.Model => ({ + crewId: 1, + nickname: '테스트유저', + gradeType: GradeType.CLUBBER, + avatarBodyUri: 'body.png', + avatarFaceUri: 'face.png', + avatarIconUri: 'icon.png', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('useReactionMotionCallback', () => { + test('해당 crewId의 motionType, reactionType을 업데이트한다', () => { + store.getState().updateCrews(() => [createCrew({ crewId: 1 }), createCrew({ crewId: 2 })]); + + const { result } = renderHook(() => useReactionMotionCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.REACTION_PERFORMED, + motionType: MotionType.DANCE_TYPE_1, + reactionType: ReactionType.LIKE, + crew: { crewId: 1 }, + }); + + const crews = store.getState().crews; + expect(crews[0].motionType).toBe(MotionType.DANCE_TYPE_1); + expect(crews[0].reactionType).toBe(ReactionType.LIKE); + }); + + test('다른 크루는 변경되지 않는다', () => { + store + .getState() + .updateCrews(() => [ + createCrew({ crewId: 1 }), + createCrew({ crewId: 2, nickname: '다른유저' }), + ]); + + const { result } = renderHook(() => useReactionMotionCallback()); + const callback = result.current; + + callback({ + eventType: PartyroomEventType.REACTION_PERFORMED, + motionType: MotionType.DANCE_TYPE_2, + reactionType: ReactionType.DISLIKE, + crew: { crewId: 1 }, + }); + + const crews = store.getState().crews; + expect(crews[1].motionType).toBe(MotionType.NONE); + expect(crews[1].reactionType).toBeUndefined(); + }); +}); diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.ts index 809def05..429a664f 100644 --- a/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.ts +++ b/src/entities/partyroom-client/lib/subscription-callbacks/use-reaction-motion-callback.hook.ts @@ -1,11 +1,11 @@ -import { ReactionMotionEvent } from '@/shared/api/websocket/types/partyroom'; +import { ReactionPerformedEvent } from '@/shared/api/websocket/types/partyroom'; import { useStores } from '@/shared/lib/store/stores.context'; export default function useReactionMotionCallback() { const { useCurrentPartyroom } = useStores(); const updateCrews = useCurrentPartyroom((state) => state.updateCrews); - return (event: ReactionMotionEvent) => { + return (event: ReactionPerformedEvent) => { updateCrews((prev) => { return prev.map((crew) => { if (crew.crewId === event.crew.crewId) { diff --git a/src/entities/partyroom-client/lib/subscription-callbacks/utils/use-invalidate-djing-queue.hook.ts b/src/entities/partyroom-client/lib/subscription-callbacks/utils/use-invalidate-djing-queue.hook.ts deleted file mode 100644 index 803efe3f..00000000 --- a/src/entities/partyroom-client/lib/subscription-callbacks/utils/use-invalidate-djing-queue.hook.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys } from '@/shared/api/http/query-keys'; -import { useStores } from '@/shared/lib/store/stores.context'; - -export default function useInvalidateDjingQueue() { - const queryClient = useQueryClient(); - const { useCurrentPartyroom } = useStores(); - const partyroomId = useCurrentPartyroom((state) => state.id); - - return () => { - queryClient.invalidateQueries({ - queryKey: [QueryKeys.DjingQueue, partyroomId], - }); - }; -} diff --git a/src/entities/partyroom-info/model/form.model.test.ts b/src/entities/partyroom-info/model/form.model.test.ts new file mode 100644 index 00000000..672c67b5 --- /dev/null +++ b/src/entities/partyroom-info/model/form.model.test.ts @@ -0,0 +1,102 @@ +import { getSchema } from './form.model'; + +const mockDictionary = { + common: { + ec: { + char_field_required: '필수 입력입니다', + char_limit_30: '30자 이내로 입력해주세요', + char_limit_50: '50자 이내로 입력해주세요', + }, + }, + createparty: { + para: { + noti_djing_limit: '최소 3명 이상이어야 합니다', + }, + }, +} as any; + +const schema = getSchema(mockDictionary); + +const validBase = { + name: '파티룸', + introduce: '소개글입니다', + limit: 10, +}; + +describe('partyroom form schema', () => { + describe('name 필드', () => { + it.each(['한글', 'Test', '123', '한Test123'])('유효: "%s"', (name) => { + expect(schema.safeParse({ ...validBase, name }).success).toBe(true); + }); + + test('경계값: 1자', () => { + expect(schema.safeParse({ ...validBase, name: '가' }).success).toBe(true); + }); + + test('경계값: 30자', () => { + expect(schema.safeParse({ ...validBase, name: '가'.repeat(30) }).success).toBe(true); + }); + + test('무효: 빈 문자열', () => { + expect(schema.safeParse({ ...validBase, name: '' }).success).toBe(false); + }); + + test('무효: 31자 초과', () => { + expect(schema.safeParse({ ...validBase, name: '가'.repeat(31) }).success).toBe(false); + }); + + it.each(['파티$', 'Test Room', '파티!@#'])('무효: 특수문자/공백 "%s"', (name) => { + expect(schema.safeParse({ ...validBase, name }).success).toBe(false); + }); + }); + + describe('introduce 필드', () => { + test('유효: 일반 텍스트', () => { + expect(schema.safeParse({ ...validBase, introduce: '안녕하세요' }).success).toBe(true); + }); + + test('경계값: 1자', () => { + expect(schema.safeParse({ ...validBase, introduce: '가' }).success).toBe(true); + }); + + test('경계값: 50자', () => { + expect(schema.safeParse({ ...validBase, introduce: '가'.repeat(50) }).success).toBe(true); + }); + + test('무효: 빈 문자열', () => { + expect(schema.safeParse({ ...validBase, introduce: '' }).success).toBe(false); + }); + + test('무효: 51자 초과', () => { + expect(schema.safeParse({ ...validBase, introduce: '가'.repeat(51) }).success).toBe(false); + }); + }); + + describe('domain 필드', () => { + test('유효: undefined', () => { + expect(schema.safeParse({ ...validBase, domain: undefined }).success).toBe(true); + }); + + it.each(['example', 'sub.domain'])('유효: "%s"', (domain) => { + expect(schema.safeParse({ ...validBase, domain }).success).toBe(true); + }); + + test('무효: 공백 포함', () => { + expect(schema.safeParse({ ...validBase, domain: 'my domain' }).success).toBe(false); + }); + }); + + describe('limit 필드', () => { + it.each([3, 100])('유효: %d', (limit) => { + expect(schema.safeParse({ ...validBase, limit }).success).toBe(true); + }); + + test('무효: 최소값 미만 (2)', () => { + expect(schema.safeParse({ ...validBase, limit: 2 }).success).toBe(false); + }); + + test('무효: 음수', () => { + expect(schema.safeParse({ ...validBase, limit: -5 }).success).toBe(false); + }); + }); +}); diff --git a/src/entities/playlist/index.ts b/src/entities/playlist/index.ts index 7ad7e297..4ab08e5d 100644 --- a/src/entities/playlist/index.ts +++ b/src/entities/playlist/index.ts @@ -1,7 +1,3 @@ -export { - default as PlaylistForm, - type FormProps as PlaylistFormProps, -} from './ui/playlist-form.component'; export type { Model as PlaylistFormValues } from './model/playlist-form.model'; export { PlaylistActionContext, diff --git a/src/entities/playlist/index.ui.ts b/src/entities/playlist/index.ui.ts new file mode 100644 index 00000000..447d3f7f --- /dev/null +++ b/src/entities/playlist/index.ui.ts @@ -0,0 +1,4 @@ +export { + default as PlaylistForm, + type FormProps as PlaylistFormProps, +} from './ui/playlist-form.component'; diff --git a/src/entities/playlist/lib/playlist-action.context.tsx b/src/entities/playlist/lib/playlist-action.context.tsx index f266bbac..b3fe0455 100644 --- a/src/entities/playlist/lib/playlist-action.context.tsx +++ b/src/entities/playlist/lib/playlist-action.context.tsx @@ -17,7 +17,7 @@ type PlaylistAction = { remove: (targetIds: Playlist['id'][], options?: PlaylistActionOptions) => void; addTrack: (targetId: Playlist['id'], track: AddTrackToPlaylistRequestBody) => void; - removeTrack: (targetId: Playlist['id'], trackIds: PlaylistTrack['linkId']) => void; + removeTrack: (targetId: Playlist['id'], trackId: PlaylistTrack['trackId']) => void; changeTrackOrder: (params: { playlistId: number; trackId: number; diff --git a/src/entities/playlist/model/playlist-form.model.test.ts b/src/entities/playlist/model/playlist-form.model.test.ts new file mode 100644 index 00000000..2c0dcc44 --- /dev/null +++ b/src/entities/playlist/model/playlist-form.model.test.ts @@ -0,0 +1,36 @@ +import { getSchema } from './playlist-form.model'; + +const mockDictionary = { + common: { + ec: { + char_field_required: '필수 입력입니다', + char_limit_20: '20자 이내로 입력해주세요', + }, + }, +} as any; + +const schema = getSchema(mockDictionary); + +describe('playlist form schema', () => { + describe('name 필드', () => { + test('유효: 일반 텍스트', () => { + expect(schema.safeParse({ name: '플리이름' }).success).toBe(true); + }); + + test('경계값: 1자', () => { + expect(schema.safeParse({ name: '가' }).success).toBe(true); + }); + + test('경계값: 20자', () => { + expect(schema.safeParse({ name: '가'.repeat(20) }).success).toBe(true); + }); + + test('무효: 빈 문자열', () => { + expect(schema.safeParse({ name: '' }).success).toBe(false); + }); + + test('무효: 21자 초과', () => { + expect(schema.safeParse({ name: '가'.repeat(21) }).success).toBe(false); + }); + }); +}); diff --git a/src/entities/preference/model/user-preference.store.test.ts b/src/entities/preference/model/user-preference.store.test.ts new file mode 100644 index 00000000..3a1bf4b1 --- /dev/null +++ b/src/entities/preference/model/user-preference.store.test.ts @@ -0,0 +1,34 @@ +import { useUserPreferenceStore } from './user-preference.store'; + +describe('user-preference store', () => { + beforeEach(() => { + // 각 테스트 전 스토어 초기화 + useUserPreferenceStore.getState().reset(); + }); + + test('초기 상태: djingGuideHidden은 false', () => { + expect(useUserPreferenceStore.getState().djingGuideHidden).toBe(false); + }); + + describe('setDjingGuideHidden', () => { + test('true로 설정', () => { + useUserPreferenceStore.getState().setDjingGuideHidden(true); + expect(useUserPreferenceStore.getState().djingGuideHidden).toBe(true); + }); + + test('false로 다시 설정', () => { + useUserPreferenceStore.getState().setDjingGuideHidden(true); + useUserPreferenceStore.getState().setDjingGuideHidden(false); + expect(useUserPreferenceStore.getState().djingGuideHidden).toBe(false); + }); + }); + + describe('reset', () => { + test('초기 상태로 복원', () => { + useUserPreferenceStore.getState().setDjingGuideHidden(true); + useUserPreferenceStore.getState().reset(); + + expect(useUserPreferenceStore.getState().djingGuideHidden).toBe(false); + }); + }); +}); diff --git a/src/entities/ui-state/model/ui-state.store.test.ts b/src/entities/ui-state/model/ui-state.store.test.ts new file mode 100644 index 00000000..06e74e3c --- /dev/null +++ b/src/entities/ui-state/model/ui-state.store.test.ts @@ -0,0 +1,104 @@ +vi.mock('@/shared/lib/functions/log/logger', () => ({ + warnLog: vi.fn(), +})); +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + default: () => (fn: any) => fn, +})); + +import { warnLog } from '@/shared/lib/functions/log/logger'; +import { createUIStateStore } from './ui-state.store'; + +const mockedWarnLog = warnLog as Mock; + +describe('ui-state store', () => { + beforeEach(() => vi.clearAllMocks()); + + test('초기 상태: open=false, interactable=true, zIndex=30, selectedPlaylist=undefined', () => { + const store = createUIStateStore(); + const { playlistDrawer } = store.getState(); + + expect(playlistDrawer.open).toBe(false); + expect(playlistDrawer.interactable).toBe(true); + expect(playlistDrawer.zIndex).toBe(30); + expect(playlistDrawer.selectedPlaylist).toBeUndefined(); + }); + + describe('setPlaylistDrawer', () => { + test('open을 true로 변경한다', () => { + const store = createUIStateStore(); + + store.getState().setPlaylistDrawer({ open: true }); + + const { playlistDrawer } = store.getState(); + expect(playlistDrawer.open).toBe(true); + expect(playlistDrawer.interactable).toBe(true); + expect(playlistDrawer.zIndex).toBe(30); + }); + + test('open=true일 때 interactable과 zIndex를 변경할 수 있다', () => { + const store = createUIStateStore(); + + store.getState().setPlaylistDrawer({ open: true, interactable: false, zIndex: 50 }); + + const { playlistDrawer } = store.getState(); + expect(playlistDrawer.open).toBe(true); + expect(playlistDrawer.interactable).toBe(false); + expect(playlistDrawer.zIndex).toBe(50); + }); + + test('open=false로 변경하면 interactable, zIndex, selectedPlaylist가 자동 초기화된다', () => { + const store = createUIStateStore(); + + // 먼저 drawer를 열고 값 변경 + store.getState().setPlaylistDrawer({ + open: true, + interactable: false, + zIndex: 999, + selectedPlaylist: { + id: 1, + name: '테스트', + orderNumber: 1, + type: 'PLAYLIST' as any, + musicCount: 5, + }, + }); + + // drawer 닫기 + store.getState().setPlaylistDrawer({ open: false }); + + const { playlistDrawer } = store.getState(); + expect(playlistDrawer.open).toBe(false); + expect(playlistDrawer.interactable).toBe(true); + expect(playlistDrawer.zIndex).toBe(30); + expect(playlistDrawer.selectedPlaylist).toBeUndefined(); + }); + + test('open=false일 때 초기값과 다른 값이 있으면 warnLog를 호출한다', () => { + const store = createUIStateStore(); + + // drawer를 열고 값 변경 + store.getState().setPlaylistDrawer({ + open: true, + interactable: false, + zIndex: 999, + }); + + // drawer 닫기 - 변경된 interactable과 zIndex에 대해 warnLog 호출 예상 + store.getState().setPlaylistDrawer({ open: false }); + + expect(mockedWarnLog).toHaveBeenCalledWith(expect.stringContaining('interactable')); + expect(mockedWarnLog).toHaveBeenCalledWith(expect.stringContaining('zIndex')); + }); + + test('open=false일 때 값이 이미 초기값이면 warnLog를 호출하지 않는다', () => { + const store = createUIStateStore(); + + // drawer를 열었다가 값 변경 없이 닫기 + store.getState().setPlaylistDrawer({ open: true }); + store.getState().setPlaylistDrawer({ open: false }); + + // interactable, zIndex, selectedPlaylist 모두 이미 초기값이므로 로그 없음 + expect(mockedWarnLog).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/entities/wallet/api/use-update-my-wallet.integration.test.ts b/src/entities/wallet/api/use-update-my-wallet.integration.test.ts new file mode 100644 index 00000000..e3c269ac --- /dev/null +++ b/src/entities/wallet/api/use-update-my-wallet.integration.test.ts @@ -0,0 +1,21 @@ +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import useUpdateMyWallet from './use-update-my-wallet.mutation'; + +describe('useUpdateMyWallet 통합', () => { + test('성공 시 Me 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useUpdateMyWallet()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ walletAddress: '0xABC' }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.Me], + }); + }); +}); diff --git a/src/entities/wallet/index.ts b/src/entities/wallet/index.ts index cb483b7d..03ad4178 100644 --- a/src/entities/wallet/index.ts +++ b/src/entities/wallet/index.ts @@ -2,5 +2,4 @@ export * as Nft from './model/nft.model'; export { default as useNfts } from './lib/use-nfts.hook'; export { default as useIsWalletLinked } from './lib/use-is-wallet-linked.hook'; export { default as useGlobalWalletSync } from './lib/use-global-wallet-sync.hook'; -export { default as ConnectWallet } from './ui/connect-wallet.component'; export { default as useInformWalletLinkage } from './ui/use-inform-wallet-linkage.hook'; diff --git a/src/entities/wallet/index.ui.ts b/src/entities/wallet/index.ui.ts new file mode 100644 index 00000000..fdedb748 --- /dev/null +++ b/src/entities/wallet/index.ui.ts @@ -0,0 +1 @@ +export { default as ConnectWallet } from './ui/connect-wallet.component'; diff --git a/src/entities/wallet/lib/use-global-wallet-sync.hook.test.ts b/src/entities/wallet/lib/use-global-wallet-sync.hook.test.ts new file mode 100644 index 00000000..a73d58b7 --- /dev/null +++ b/src/entities/wallet/lib/use-global-wallet-sync.hook.test.ts @@ -0,0 +1,54 @@ +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); +vi.mock('../api/use-update-my-wallet.mutation', () => ({ + __esModule: true, + default: vi.fn(), +})); + +import { renderHook } from '@testing-library/react'; +import { useAccount } from 'wagmi'; +import useGlobalWalletSync from './use-global-wallet-sync.hook'; +import useUpdateMyWallet from '../api/use-update-my-wallet.mutation'; + +const mockMutate = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useUpdateMyWallet as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useGlobalWalletSync', () => { + test('지갑이 연결되고 주소가 다르면 updateMyWallet을 호출한다', () => { + (useAccount as Mock).mockReturnValue({ + address: '0xNewAddress', + isConnected: true, + }); + + renderHook(() => useGlobalWalletSync('0xOldAddress')); + + expect(mockMutate).toHaveBeenCalledWith({ walletAddress: '0xNewAddress' }); + }); + + test('지갑 연결 해제 시 빈 주소로 updateMyWallet을 호출한다', () => { + (useAccount as Mock).mockReturnValue({ + address: undefined, + isConnected: false, + }); + + renderHook(() => useGlobalWalletSync('0xOldAddress')); + + expect(mockMutate).toHaveBeenCalledWith({ walletAddress: '' }); + }); + + test('지갑 주소가 동일하면 updateMyWallet을 호출하지 않는다', () => { + (useAccount as Mock).mockReturnValue({ + address: '0xSameAddress', + isConnected: true, + }); + + renderHook(() => useGlobalWalletSync('0xSameAddress')); + + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entities/wallet/lib/use-is-nft.hook.test.ts b/src/entities/wallet/lib/use-is-nft.hook.test.ts new file mode 100644 index 00000000..4729b2fb --- /dev/null +++ b/src/entities/wallet/lib/use-is-nft.hook.test.ts @@ -0,0 +1,39 @@ +const mockGetQueryData = vi.fn(); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ getQueryData: mockGetQueryData }), +})); + +import { renderHook } from '@testing-library/react'; +import useIsNft from './use-is-nft.hook'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useIsNft', () => { + test('NFT 목록에 URI가 존재하면 true를 반환한다', () => { + mockGetQueryData.mockReturnValue([ + { resourceUri: 'https://example.com/nft1.png', available: true }, + { resourceUri: 'https://example.com/nft2.png', available: true }, + ]); + + const { result } = renderHook(() => useIsNft()); + expect(result.current('https://example.com/nft1.png')).toBe(true); + }); + + test('NFT 목록에 URI가 없으면 false를 반환한다', () => { + mockGetQueryData.mockReturnValue([ + { resourceUri: 'https://example.com/nft1.png', available: true }, + ]); + + const { result } = renderHook(() => useIsNft()); + expect(result.current('https://example.com/nonexistent.png')).toBe(false); + }); + + test('NFT 데이터가 없으면 falsy를 반환한다', () => { + mockGetQueryData.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsNft()); + expect(result.current('https://example.com/nft1.png')).toBeFalsy(); + }); +}); diff --git a/src/entities/wallet/lib/use-is-wallet-linked.hook.test.ts b/src/entities/wallet/lib/use-is-wallet-linked.hook.test.ts new file mode 100644 index 00000000..fa1d3b7e --- /dev/null +++ b/src/entities/wallet/lib/use-is-wallet-linked.hook.test.ts @@ -0,0 +1,50 @@ +const mockFetchMeAsync = vi.fn(); +vi.mock('@/entities/me', () => ({ + useFetchMeAsync: () => mockFetchMeAsync, +})); + +import { renderHook } from '@testing-library/react'; +import { AuthorityTier } from '@/shared/api/http/types/@enums'; +import useIsWalletLinked from './use-is-wallet-linked.hook'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useIsWalletLinked', () => { + test('FM이고 walletAddress가 있으면 true를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ + authorityTier: AuthorityTier.FM, + walletAddress: '0x1234', + }); + + const { result } = renderHook(() => useIsWalletLinked()); + const isLinked = await result.current(); + + expect(isLinked).toBe(true); + }); + + test('FM이지만 walletAddress가 없으면 false를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ + authorityTier: AuthorityTier.FM, + walletAddress: '', + }); + + const { result } = renderHook(() => useIsWalletLinked()); + const isLinked = await result.current(); + + expect(isLinked).toBe(false); + }); + + test('GT이면 walletAddress와 무관하게 false를 반환한다', async () => { + mockFetchMeAsync.mockResolvedValue({ + authorityTier: AuthorityTier.GT, + walletAddress: '0x1234', + }); + + const { result } = renderHook(() => useIsWalletLinked()); + const isLinked = await result.current(); + + expect(isLinked).toBe(false); + }); +}); diff --git a/src/entities/wallet/model/nft.model.test.ts b/src/entities/wallet/model/nft.model.test.ts new file mode 100644 index 00000000..913ca97f --- /dev/null +++ b/src/entities/wallet/model/nft.model.test.ts @@ -0,0 +1,70 @@ +import axios from 'axios'; +import { refineList } from './nft.model'; + +vi.mock('axios'); +const mockedAxios = axios as Mocked; + +const createNft = (thumbnailUrl: string | null = 'https://example.com/nft.png', name = 'TestNFT') => + ({ + name, + image: { thumbnailUrl }, + }) as any; + +describe('nft model', () => { + describe('refineList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('이미지 헬스체크 통과한 NFT만 반환', async () => { + mockedAxios.get.mockResolvedValue({ status: 200 }); + + const result = await refineList([createNft('https://ok.com/1.png', 'NFT1')]); + + expect(result).toEqual([ + { + name: 'NFT1', + resourceUri: 'https://ok.com/1.png', + available: true, + }, + ]); + }); + + test('이미지 헬스체크 실패한 NFT는 제외', async () => { + mockedAxios.get.mockRejectedValue(new Error('network error')); + + const result = await refineList([createNft('https://fail.com/1.png')]); + + expect(result).toEqual([]); + }); + + test('thumbnailUrl이 없는 NFT는 제외', async () => { + const result = await refineList([createNft(null)]); + + expect(result).toEqual([]); + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); + + test('여러 NFT 중 성공/실패 혼합', async () => { + mockedAxios.get + .mockResolvedValueOnce({ status: 200 }) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ status: 200 }); + + const result = await refineList([ + createNft('https://ok1.com/1.png', 'A'), + createNft('https://fail.com/2.png', 'B'), + createNft('https://ok2.com/3.png', 'C'), + ]); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('A'); + expect(result[1].name).toBe('C'); + }); + + test('빈 배열이면 빈 배열 반환', async () => { + const result = await refineList([]); + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/features/edit-profile-avatar/api/avatar.integration.test.ts b/src/features/edit-profile-avatar/api/avatar.integration.test.ts new file mode 100644 index 00000000..3c8fdf75 --- /dev/null +++ b/src/features/edit-profile-avatar/api/avatar.integration.test.ts @@ -0,0 +1,25 @@ +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useFetchAvatarBodies } from './use-fetch-avatar-bodies.query'; +import { useFetchAvatarFaces } from './use-fetch-avatar-faces.query'; + +describe('useFetchAvatarBodies 통합', () => { + test('아바타 바디 목록을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchAvatarBodies()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0]).toHaveProperty('name', 'Body A'); + }); +}); + +describe('useFetchAvatarFaces 통합', () => { + test('아바타 페이스 목록을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchAvatarFaces()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data?.[0]).toHaveProperty('name', 'Face A'); + }); +}); diff --git a/src/features/edit-profile-avatar/api/use-update-my-avatar.integration.test.ts b/src/features/edit-profile-avatar/api/use-update-my-avatar.integration.test.ts new file mode 100644 index 00000000..7f9d7812 --- /dev/null +++ b/src/features/edit-profile-avatar/api/use-update-my-avatar.integration.test.ts @@ -0,0 +1,80 @@ +vi.mock('@/entities/wallet/lib/use-is-nft.hook'); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import useIsNft from '@/entities/wallet/lib/use-is-nft.hook'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { ObtainmentType } from '@/shared/api/http/types/@enums'; +import { AvatarBody } from '@/shared/api/http/types/users'; +import { useUpdateMyAvatar } from './use-update-my-avatar.mutation'; + +const singleBody: AvatarBody = { + id: 1, + name: 'Body A', + resourceUri: 'https://example.com/body-a.png', + available: true, + obtainableType: ObtainmentType.BASIC, + obtainableScore: 0, + combinable: false, + defaultSetting: true, +}; + +const combinableBody: AvatarBody = { + id: 2, + name: 'Body B', + resourceUri: 'https://example.com/body-b.png', + available: true, + obtainableType: ObtainmentType.BASIC, + obtainableScore: 0, + combinable: true, + defaultSetting: false, + combinePositionX: 0.5, + combinePositionY: 0.3, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (useIsNft as Mock).mockReturnValue(() => false); +}); + +describe('useUpdateMyAvatar 통합', () => { + test('non-combinable body → SINGLE_BODY로 업데이트 성공', async () => { + const { result, queryClient } = renderWithClient(() => useUpdateMyAvatar()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ body: singleBody }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.Me], + }); + }); + + test('combinable body + face → BODY_WITH_FACE로 업데이트 성공', async () => { + const { result } = renderWithClient(() => useUpdateMyAvatar()); + + await act(async () => { + result.current.mutate({ + body: combinableBody, + faceUri: 'https://example.com/face.png', + facePos: { offsetX: 0.5, offsetY: 0.3, scale: 1 }, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); + + test('combinable body에 faceUri 없으면 에러', async () => { + const { result } = renderWithClient(() => useUpdateMyAvatar()); + + await act(async () => { + result.current.mutate({ body: combinableBody }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toContain('faceUri and facePos are required'); + }); +}); diff --git a/src/features/edit-profile-avatar/lib/selected-avatar-state.provider.tsx b/src/features/edit-profile-avatar/lib/selected-avatar-state.provider.tsx index 28d75e2e..a77d2eb5 100644 --- a/src/features/edit-profile-avatar/lib/selected-avatar-state.provider.tsx +++ b/src/features/edit-profile-avatar/lib/selected-avatar-state.provider.tsx @@ -41,7 +41,7 @@ export default function SelectedAvatarStateProvider({ children }: { children: Re scale: me.scale, }); } - }, [bodies, selectedBody, selectedFacePos]); + }, [bodies, selectedBody]); return ( diff --git a/src/features/edit-profile-avatar/model/avatar-body.model.test.ts b/src/features/edit-profile-avatar/model/avatar-body.model.test.ts new file mode 100644 index 00000000..4252d01e --- /dev/null +++ b/src/features/edit-profile-avatar/model/avatar-body.model.test.ts @@ -0,0 +1,106 @@ +import type { Me } from '@/entities/me'; +import { ActivityType, AuthorityTier, ObtainmentType } from '@/shared/api/http/types/@enums'; +import type { AvatarBody } from '@/shared/api/http/types/users'; +import { locked } from './avatar-body.model'; + +const createMe = (overrides: Partial = {}): Me.Model => ({ + uid: 'test-uid', + authorityTier: AuthorityTier.FM, + registrationDate: '2024-06-23', + profileUpdated: true, + nickname: 'tester', + avatarBodyUri: '', + avatarFaceUri: '', + avatarIconUri: '', + activitySummaries: [], + offsetX: 0, + offsetY: 0, + scale: 1, + ...overrides, +}); + +const createBody = (overrides: Partial = {}): AvatarBody => ({ + id: 1, + name: 'body1', + resourceUri: '/body.png', + available: true, + obtainableType: ObtainmentType.BASIC, + obtainableScore: 0, + combinable: true, + defaultSetting: false, + ...overrides, +}); + +const mockDictionary = { + common: { + para: { + 'points_to_unlock\t': '{{points}} 포인트가 필요합니다', + }, + }, +} as any; + +describe('avatar-body model', () => { + describe('locked', () => { + test('me가 undefined이면 잠김', () => { + const body = createBody(); + const result = locked(body, undefined, mockDictionary); + expect(result).toEqual({ is: true }); + }); + + test('BASIC 타입은 항상 잠기지 않음', () => { + const body = createBody({ obtainableType: ObtainmentType.BASIC }); + const me = createMe(); + const result = locked(body, me, mockDictionary); + expect(result).toEqual({ is: false }); + }); + + test('DJ_PNT 타입 — 점수 충분하면 잠기지 않음', () => { + const body = createBody({ + obtainableType: ObtainmentType.DJ_PNT, + obtainableScore: 100, + }); + const me = createMe({ + activitySummaries: [{ activityType: ActivityType.DJ_PNT, score: 150 }], + }); + const result = locked(body, me, mockDictionary); + expect(result).toEqual({ is: false }); + }); + + test('DJ_PNT 타입 — 점수 부족하면 잠김 + reason 포함', () => { + const body = createBody({ + obtainableType: ObtainmentType.DJ_PNT, + obtainableScore: 100, + }); + const me = createMe({ + activitySummaries: [{ activityType: ActivityType.DJ_PNT, score: 30 }], + }); + const result = locked(body, me, mockDictionary); + expect(result.is).toBe(true); + expect(result.reason).toBeDefined(); + expect(result.reason).toContain('70 DJ'); + }); + + test('REF_LINK 타입 — 점수 부족하면 잠김', () => { + const body = createBody({ + obtainableType: ObtainmentType.REF_LINK, + obtainableScore: 50, + }); + const me = createMe({ activitySummaries: [] }); + const result = locked(body, me, mockDictionary); + expect(result.is).toBe(true); + expect(result.reason).toContain('50 Refferal Link'); + }); + + test('ROOM_ACT 타입 — 점수 동일하면 잠기지 않음', () => { + const body = createBody({ + obtainableType: ObtainmentType.ROOM_ACT, + obtainableScore: 200, + }); + const me = createMe({ + activitySummaries: [{ activityType: ActivityType.ROOM_ACT, score: 200 }], + }); + const result = locked(body, me, mockDictionary); + expect(result).toEqual({ is: false }); + }); + }); +}); diff --git a/src/features/edit-profile-avatar/ui/connect-wallet-button.component.tsx b/src/features/edit-profile-avatar/ui/connect-wallet-button.component.tsx index f4302d8f..223cff5f 100644 --- a/src/features/edit-profile-avatar/ui/connect-wallet-button.component.tsx +++ b/src/features/edit-profile-avatar/ui/connect-wallet-button.component.tsx @@ -1,5 +1,5 @@ 'use client'; -import { ConnectWallet } from '@/entities/wallet'; +import { ConnectWallet } from '@/entities/wallet/index.ui'; import SuspenseWithErrorBoundary from '@/shared/api/http/error/suspense-with-error-boundary.component'; import { Button } from '@/shared/ui/components/button'; diff --git a/src/features/edit-profile-avatar/ui/selected-avatar.component.tsx b/src/features/edit-profile-avatar/ui/selected-avatar.component.tsx index 10e3bec6..4682c2d0 100644 --- a/src/features/edit-profile-avatar/ui/selected-avatar.component.tsx +++ b/src/features/edit-profile-avatar/ui/selected-avatar.component.tsx @@ -1,4 +1,5 @@ import { Avatar } from '@/entities/avatar'; +import { AvatarCompositionType } from '@/shared/api/http/types/@enums'; import { useSelectedAvatarState } from '../lib/selected-avatar-state.context'; const SelectedAvatar = () => { @@ -10,6 +11,11 @@ const SelectedAvatar = () => { { + test('성공 시 Me 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useUpdateMyBio()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ nickname: 'NewName', introduction: 'Hello' }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.Me], + }); + }); +}); diff --git a/src/features/edit-profile-bio/model/form.model.test.ts b/src/features/edit-profile-bio/model/form.model.test.ts new file mode 100644 index 00000000..191f684a --- /dev/null +++ b/src/features/edit-profile-bio/model/form.model.test.ts @@ -0,0 +1,63 @@ +import { getSchema } from './form.model'; + +const mockDictionary = { + common: { + ec: { + char_field_required: '필수 입력입니다', + char_limit_12: '12자 이내로 입력해주세요', + char_limit_50: '50자 이내로 입력해주세요', + }, + }, +} as any; + +const schema = getSchema(mockDictionary); + +describe('edit-profile-bio form schema', () => { + describe('nickname 필드', () => { + it.each(['한글', 'Test', '123', '한Test123'])('유효: "%s"', (nickname) => { + expect(schema.safeParse({ nickname }).success).toBe(true); + }); + + test('경계값: 1자', () => { + expect(schema.safeParse({ nickname: '가' }).success).toBe(true); + }); + + test('경계값: 12자', () => { + expect(schema.safeParse({ nickname: '가'.repeat(12) }).success).toBe(true); + }); + + test('무효: 빈 문자열', () => { + expect(schema.safeParse({ nickname: '' }).success).toBe(false); + }); + + test('무효: 13자 초과', () => { + expect(schema.safeParse({ nickname: '가'.repeat(13) }).success).toBe(false); + }); + + it.each(['닉네임$', 'Test Name', '유저!@#'])('무효: 특수문자/공백 "%s"', (nickname) => { + expect(schema.safeParse({ nickname }).success).toBe(false); + }); + }); + + describe('introduction 필드', () => { + test('유효: undefined', () => { + expect(schema.safeParse({ nickname: '테스트' }).success).toBe(true); + }); + + test('유효: 빈 문자열', () => { + expect(schema.safeParse({ nickname: '테스트', introduction: '' }).success).toBe(true); + }); + + test('유효: 50자 텍스트', () => { + expect(schema.safeParse({ nickname: '테스트', introduction: '가'.repeat(50) }).success).toBe( + true + ); + }); + + test('무효: 51자 초과', () => { + expect(schema.safeParse({ nickname: '테스트', introduction: '가'.repeat(51) }).success).toBe( + false + ); + }); + }); +}); diff --git a/src/features/edit-profile-bio/ui/v2-edit.component.tsx b/src/features/edit-profile-bio/ui/v2-edit.component.tsx index 14ff8f84..4629ee75 100644 --- a/src/features/edit-profile-bio/ui/v2-edit.component.tsx +++ b/src/features/edit-profile-bio/ui/v2-edit.component.tsx @@ -55,6 +55,7 @@ const V2EditMode = ({ changeToViewMode }: V2EditModeProps) => { ; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanAdjustGrade', () => { + test('me가 없으면 항상 false를 반환한다', () => { + const { result } = renderHook(() => useCanAdjustGrade()); + expect(result.current(GradeType.CLUBBER)).toBe(false); + }); + + test('HOST는 하위 등급을 조정할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanAdjustGrade()); + + expect(result.current(GradeType.MODERATOR)).toBe(true); + expect(result.current(GradeType.CLUBBER)).toBe(true); + expect(result.current(GradeType.LISTENER)).toBe(true); + }); + + test('HOST는 자기 등급을 조정할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanAdjustGrade()); + expect(result.current(GradeType.HOST)).toBe(false); + }); + + test('CLUBBER는 등급 조정 권한이 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanAdjustGrade()); + expect(result.current(GradeType.LISTENER)).toBe(false); + }); + + test('MODERATOR는 하위 등급만 조정할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanAdjustGrade()); + + expect(result.current(GradeType.CLUBBER)).toBe(true); + expect(result.current(GradeType.MODERATOR)).toBe(false); + expect(result.current(GradeType.HOST)).toBe(false); + }); +}); diff --git a/src/features/partyroom/adjust-grade/lib/use-adjust-grade.hook.test.tsx b/src/features/partyroom/adjust-grade/lib/use-adjust-grade.hook.test.tsx new file mode 100644 index 00000000..5ca0e394 --- /dev/null +++ b/src/features/partyroom/adjust-grade/lib/use-adjust-grade.hook.test.tsx @@ -0,0 +1,95 @@ +vi.mock('@/entities/current-partyroom', () => ({ + Crew: { + Permission: { + of: (_grade: string) => ({ + adjustableGrades: ['CLUBBER', 'LISTENER'], + }), + }, + }, + useOpenGradeAdjustmentAlertDialog: vi.fn(), +})); +vi.mock('../api/use-adjust-grade.mutation'); +vi.mock('../api/use-can-adjust-grade.hook'); +vi.mock('./use-select-grade.hook'); +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook, act } from '@testing-library/react'; +import { useOpenGradeAdjustmentAlertDialog } from '@/entities/current-partyroom'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useAdjustGrade } from './use-adjust-grade.hook'; +import { useSelectGrade } from './use-select-grade.hook'; +import { useAdjustGrade as useAdjustGradeMutation } from '../api/use-adjust-grade.mutation'; +import useCanAdjustGrade from '../api/use-can-adjust-grade.hook'; + +const mockMutate = vi.fn(); +const mockSelectGrade = vi.fn(); +const mockOpenAlert = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useAdjustGradeMutation as Mock).mockReturnValue({ mutate: mockMutate }); + (useSelectGrade as Mock).mockReturnValue(mockSelectGrade); + (useOpenGradeAdjustmentAlertDialog as Mock).mockReturnValue(mockOpenAlert); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ me: { crewId: 1, gradeType: GradeType.HOST }, id: 1 }), + }); +}); + +describe('useAdjustGrade', () => { + test('권한 없으면 다이얼로그를 열지 않는다', async () => { + (useCanAdjustGrade as Mock).mockReturnValue(() => false); + + const { result } = renderHook(() => useAdjustGrade()); + await act(async () => { + await result.current({ crewId: 2, nickname: 'User', gradeType: GradeType.CLUBBER }); + }); + + expect(mockSelectGrade).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('같은 등급 선택 시 mutate를 호출하지 않는다', async () => { + (useCanAdjustGrade as Mock).mockReturnValue(() => true); + mockSelectGrade.mockResolvedValue(GradeType.CLUBBER); + + const { result } = renderHook(() => useAdjustGrade()); + await act(async () => { + await result.current({ crewId: 2, nickname: 'User', gradeType: GradeType.CLUBBER }); + }); + + expect(mockSelectGrade).toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('다른 등급 선택 시 mutate를 호출한다', async () => { + (useCanAdjustGrade as Mock).mockReturnValue(() => true); + mockSelectGrade.mockResolvedValue(GradeType.LISTENER); + + const { result } = renderHook(() => useAdjustGrade()); + await act(async () => { + await result.current({ crewId: 2, nickname: 'User', gradeType: GradeType.CLUBBER }); + }); + + expect(mockMutate).toHaveBeenCalledWith( + { partyroomId: 1, crewId: 2, gradeType: GradeType.LISTENER }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); + + test('mutate 성공 시 등급 변경 알림 다이얼로그를 연다', async () => { + (useCanAdjustGrade as Mock).mockReturnValue(() => true); + mockSelectGrade.mockResolvedValue(GradeType.LISTENER); + mockMutate.mockImplementation((_payload: any, options: any) => { + options.onSuccess(); + }); + + const { result } = renderHook(() => useAdjustGrade()); + await act(async () => { + await result.current({ crewId: 2, nickname: 'User', gradeType: GradeType.CLUBBER }); + }); + + expect(mockOpenAlert).toHaveBeenCalledWith(GradeType.CLUBBER, GradeType.LISTENER); + }); +}); diff --git a/src/features/partyroom/adjust-grade/lib/use-select-grade.hook.tsx b/src/features/partyroom/adjust-grade/lib/use-select-grade.hook.tsx index da01fd9e..2003ee64 100644 --- a/src/features/partyroom/adjust-grade/lib/use-select-grade.hook.tsx +++ b/src/features/partyroom/adjust-grade/lib/use-select-grade.hook.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import { GRADE_TYPE_LABEL } from '@/entities/current-partyroom'; import { GradeType } from '@/shared/api/http/types/@enums'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { Trans, BoldProcessor, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { BoldProcessor, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { Dialog, useDialog } from '@/shared/ui/components/dialog'; import { Select } from '@/shared/ui/components/select'; import { Tag } from '@/shared/ui/components/tag'; diff --git a/src/features/partyroom/block-crew/api/use-block-crew.integration.test.ts b/src/features/partyroom/block-crew/api/use-block-crew.integration.test.ts new file mode 100644 index 00000000..2eca1956 --- /dev/null +++ b/src/features/partyroom/block-crew/api/use-block-crew.integration.test.ts @@ -0,0 +1,42 @@ +import { waitFor } from '@testing-library/react'; +import '@/shared/api/__test__/msw-server'; +import useUnblockCrew from '@/features/partyroom/unblock-crew/api/use-unblock-crew.mutation'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import useBlockCrew from './use-block-crew.mutation'; + +describe('Crew block/unblock integration (hook → service → MSW)', () => { + describe('useBlockCrew', () => { + it('resolves on success and invalidates MyBlocks cache', async () => { + const { result, queryClient } = renderWithClient(() => useBlockCrew()); + + queryClient.setQueryData([QueryKeys.MyBlocks], []); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ crewId: 55 }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.MyBlocks] }) + ); + }); + }); + + describe('useUnblockCrew', () => { + it('resolves on success and invalidates MyBlocks cache', async () => { + const { result, queryClient } = renderWithClient(() => useUnblockCrew()); + + queryClient.setQueryData([QueryKeys.MyBlocks], []); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ blockId: 1 }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.MyBlocks] }) + ); + }); + }); +}); diff --git a/src/features/partyroom/block-crew/lib/use-block-crew.hook.test.ts b/src/features/partyroom/block-crew/lib/use-block-crew.hook.test.ts new file mode 100644 index 00000000..0ee2d2cf --- /dev/null +++ b/src/features/partyroom/block-crew/lib/use-block-crew.hook.test.ts @@ -0,0 +1,72 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('../api/use-block-crew.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useBlockCrew from './use-block-crew.hook'; +import useBlockCrewMutation from '../api/use-block-crew.mutation'; + +const mockMutate = vi.fn(); +const mockOpenAlertDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ + crews: [ + { crewId: 1, nickname: 'Alice' }, + { crewId: 2, nickname: 'Bob' }, + ], + }), + }); + (useDialog as Mock).mockReturnValue({ openAlertDialog: mockOpenAlertDialog }); + (useBlockCrewMutation as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useBlockCrew hook', () => { + test('mutate를 payload와 함께 호출한다', () => { + const { result } = renderHook(() => useBlockCrew()); + + act(() => { + result.current({ crewId: 1 }); + }); + + expect(mockMutate).toHaveBeenCalledWith( + { crewId: 1 }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); + + test('성공 시 crew 닉네임으로 alert를 표시한다', () => { + mockMutate.mockImplementation((_payload: any, options: any) => { + options.onSuccess(); + }); + const { result } = renderHook(() => useBlockCrew()); + + act(() => { + result.current({ crewId: 1 }); + }); + + expect(mockOpenAlertDialog).toHaveBeenCalledWith({ + content: 'Alice has been blocked.', + }); + }); + + test('crew를 찾지 못하면 기본 텍스트를 사용한다', () => { + mockMutate.mockImplementation((_payload: any, options: any) => { + options.onSuccess(); + }); + const { result } = renderHook(() => useBlockCrew()); + + act(() => { + result.current({ crewId: 999 }); + }); + + expect(mockOpenAlertDialog).toHaveBeenCalledWith({ + content: 'Crew has been blocked.', + }); + }); +}); diff --git a/src/features/partyroom/close/api/use-can-close-current-partyroom.hook.test.ts b/src/features/partyroom/close/api/use-can-close-current-partyroom.hook.test.ts new file mode 100644 index 00000000..142543e3 --- /dev/null +++ b/src/features/partyroom/close/api/use-can-close-current-partyroom.hook.test.ts @@ -0,0 +1,40 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanCloseCurrentPartyroom from './use-can-close-current-partyroom.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanCloseCurrentPartyroom', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => useCanCloseCurrentPartyroom()); + expect(result.current).toBe(false); + }); + + test('HOST는 파티룸을 닫을 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanCloseCurrentPartyroom()); + expect(result.current).toBe(true); + }); + + test('MODERATOR는 파티룸을 닫을 수 없다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanCloseCurrentPartyroom()); + expect(result.current).toBe(false); + }); + + test('CLUBBER는 파티룸을 닫을 수 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanCloseCurrentPartyroom()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/close/api/use-close-partyroom.integration.test.ts b/src/features/partyroom/close/api/use-close-partyroom.integration.test.ts new file mode 100644 index 00000000..164f1ef1 --- /dev/null +++ b/src/features/partyroom/close/api/use-close-partyroom.integration.test.ts @@ -0,0 +1,47 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/entities/current-partyroom', () => ({ + useRemoveCurrentPartyroomCaches: () => mockRemoveCaches, +})); + +const mockRemoveCaches = vi.fn(); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useClosePartyroom from './use-close-partyroom.mutation'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useClosePartyroom 통합', () => { + test('성공 시 removeCurrentPartyroomCaches를 호출한다', async () => { + const { result } = renderWithClient(() => useClosePartyroom()); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockRemoveCaches).toHaveBeenCalledTimes(1); + }); + + test('partyroomId가 없으면 에러를 던진다', async () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: undefined }), + }); + + const { result } = renderWithClient(() => useClosePartyroom()); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toContain('partyroomId'); + }); +}); diff --git a/src/features/partyroom/close/lib/use-close-partyroom.hook.test.tsx b/src/features/partyroom/close/lib/use-close-partyroom.hook.test.tsx new file mode 100644 index 00000000..b3a59b8f --- /dev/null +++ b/src/features/partyroom/close/lib/use-close-partyroom.hook.test.tsx @@ -0,0 +1,64 @@ +vi.mock('../api/use-close-partyroom.mutation'); +vi.mock('../api/use-can-close-current-partyroom.hook'); +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); + +import { renderHook, act } from '@testing-library/react'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useClosePartyroom from './use-close-partyroom.hook'; +import useCanCloseCurrentPartyroom from '../api/use-can-close-current-partyroom.hook'; +import useClosePartyroomMutation from '../api/use-close-partyroom.mutation'; + +const mockMutate = vi.fn(); +const mockOpenDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useClosePartyroomMutation as Mock).mockReturnValue({ mutate: mockMutate }); + (useI18n as Mock).mockReturnValue({ + party: { para: { match_for_close_party: 'CLOSE', close: 'Close' } }, + common: { btn: { cancel: 'Cancel' } }, + }); + (useDialog as Mock).mockReturnValue({ openDialog: mockOpenDialog }); +}); + +describe('useClosePartyroom', () => { + test('canClose가 false면 다이얼로그를 열지 않는다', async () => { + (useCanCloseCurrentPartyroom as Mock).mockReturnValue(false); + + const { result } = renderHook(() => useClosePartyroom()); + await act(async () => { + await result.current(); + }); + + expect(mockOpenDialog).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('다이얼로그 확인 시 mutate를 호출한다', async () => { + (useCanCloseCurrentPartyroom as Mock).mockReturnValue(true); + mockOpenDialog.mockResolvedValue(true); + + const { result } = renderHook(() => useClosePartyroom()); + await act(async () => { + await result.current(); + }); + + expect(mockOpenDialog).toHaveBeenCalled(); + expect(mockMutate).toHaveBeenCalled(); + }); + + test('다이얼로그 취소 시 mutate를 호출하지 않는다', async () => { + (useCanCloseCurrentPartyroom as Mock).mockReturnValue(true); + mockOpenDialog.mockResolvedValue(false); + + const { result } = renderHook(() => useClosePartyroom()); + await act(async () => { + await result.current(); + }); + + expect(mockOpenDialog).toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/close/lib/use-close-partyroom.hook.tsx b/src/features/partyroom/close/lib/use-close-partyroom.hook.tsx index adddb71f..bb098a3b 100644 --- a/src/features/partyroom/close/lib/use-close-partyroom.hook.tsx +++ b/src/features/partyroom/close/lib/use-close-partyroom.hook.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { Trans, LineBreakProcessor, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { LineBreakProcessor, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { useDialog } from '@/shared/ui/components/dialog'; import Dialog from '@/shared/ui/components/dialog/dialog.component'; import { Input } from '@/shared/ui/components/input'; diff --git a/src/features/partyroom/delete-dj-from-queue/lib/use-can-delete-dj-from-queue.hook.test.ts b/src/features/partyroom/delete-dj-from-queue/lib/use-can-delete-dj-from-queue.hook.test.ts new file mode 100644 index 00000000..a48dd255 --- /dev/null +++ b/src/features/partyroom/delete-dj-from-queue/lib/use-can-delete-dj-from-queue.hook.test.ts @@ -0,0 +1,34 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanDeleteDjFromQueue from './use-can-delete-dj-from-queue.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanDeleteDjFromQueue', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => useCanDeleteDjFromQueue()); + expect(result.current).toBe(false); + }); + + test('MODERATOR는 DJ를 대기열에서 삭제할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanDeleteDjFromQueue()); + expect(result.current).toBe(true); + }); + + test('CLUBBER는 DJ를 대기열에서 삭제할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanDeleteDjFromQueue()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/delete-dj-from-queue/lib/use-delete-dj-from-queue.hook.test.ts b/src/features/partyroom/delete-dj-from-queue/lib/use-delete-dj-from-queue.hook.test.ts new file mode 100644 index 00000000..31f19e7f --- /dev/null +++ b/src/features/partyroom/delete-dj-from-queue/lib/use-delete-dj-from-queue.hook.test.ts @@ -0,0 +1,59 @@ +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('./use-can-delete-dj-from-queue.hook'); +vi.mock('../api/use-delete-dj-from-queue.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useCanDeleteDjFromQueue from './use-can-delete-dj-from-queue.hook'; +import useDeleteDjFromQueueHook from './use-delete-dj-from-queue.hook'; +import useDeleteDjFromQueueMutation from '../api/use-delete-dj-from-queue.mutation'; + +const mockMutate = vi.fn(); +const mockOpenConfirmDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ dj: { para: { delete_dj_queue: 'Delete DJ?' } } }); + (useDialog as Mock).mockReturnValue({ openConfirmDialog: mockOpenConfirmDialog }); + (useDeleteDjFromQueueMutation as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useDeleteDjFromQueue hook', () => { + test('권한이 없으면 dialog를 열지 않는다', async () => { + (useCanDeleteDjFromQueue as Mock).mockReturnValue(false); + const { result } = renderHook(() => useDeleteDjFromQueueHook()); + + await act(async () => { + await result.current('dj-1'); + }); + + expect(mockOpenConfirmDialog).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('확인하면 djId로 mutate를 호출한다', async () => { + (useCanDeleteDjFromQueue as Mock).mockReturnValue(true); + mockOpenConfirmDialog.mockResolvedValue(true); + const { result } = renderHook(() => useDeleteDjFromQueueHook()); + + await act(async () => { + await result.current('dj-1'); + }); + + expect(mockMutate).toHaveBeenCalledWith('dj-1'); + }); + + test('취소하면 mutate를 호출하지 않는다', async () => { + (useCanDeleteDjFromQueue as Mock).mockReturnValue(true); + mockOpenConfirmDialog.mockResolvedValue(false); + const { result } = renderHook(() => useDeleteDjFromQueueHook()); + + await act(async () => { + await result.current('dj-1'); + }); + + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/edit/api/use-can-edit-current-partyroom.hook.test.ts b/src/features/partyroom/edit/api/use-can-edit-current-partyroom.hook.test.ts new file mode 100644 index 00000000..d2766e50 --- /dev/null +++ b/src/features/partyroom/edit/api/use-can-edit-current-partyroom.hook.test.ts @@ -0,0 +1,34 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanEditCurrentPartyroom from './use-can-edit-current-partyroom.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanEditCurrentPartyroom', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => useCanEditCurrentPartyroom()); + expect(result.current).toBe(false); + }); + + test('HOST는 파티룸을 수정할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanEditCurrentPartyroom()); + expect(result.current).toBe(true); + }); + + test('MODERATOR는 파티룸을 수정할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanEditCurrentPartyroom()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/edit/api/use-edit-partyroom.integration.test.ts b/src/features/partyroom/edit/api/use-edit-partyroom.integration.test.ts new file mode 100644 index 00000000..2066e464 --- /dev/null +++ b/src/features/partyroom/edit/api/use-edit-partyroom.integration.test.ts @@ -0,0 +1,35 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useEditPartyroom from './use-edit-partyroom.mutation'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useEditPartyroom 통합', () => { + test('성공 시 PartyroomDetailSummary 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useEditPartyroom()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ + title: 'New Title', + introduction: 'New Intro', + playbackTimeLimit: 300, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.PartyroomDetailSummary, 1], + }); + }); +}); diff --git a/src/features/partyroom/edit/ui/trigger.component.tsx b/src/features/partyroom/edit/ui/trigger.component.tsx index 33584833..a5dad666 100644 --- a/src/features/partyroom/edit/ui/trigger.component.tsx +++ b/src/features/partyroom/edit/ui/trigger.component.tsx @@ -4,7 +4,8 @@ import { PartyroomMutationFormModel } from '@/entities/partyroom-info'; import { Language } from '@/shared/lib/localization/constants'; import { useI18n } from '@/shared/lib/localization/i18n.context'; import { useLang } from '@/shared/lib/localization/lang.context'; -import { Trans, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { Button } from '@/shared/ui/components/button'; import { useDialog } from '@/shared/ui/components/dialog'; import { PFEdit } from '@/shared/ui/icons'; diff --git a/src/features/partyroom/enter/api/use-enter-partyroom.integration.test.ts b/src/features/partyroom/enter/api/use-enter-partyroom.integration.test.ts new file mode 100644 index 00000000..968f4286 --- /dev/null +++ b/src/features/partyroom/enter/api/use-enter-partyroom.integration.test.ts @@ -0,0 +1,94 @@ +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { useAdjustGrade } from '@/features/partyroom/adjust-grade/api/use-adjust-grade.mutation'; +import useCreatePartyroom from '@/features/partyroom/create/api/use-create-partyroom.mutation'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { ErrorCode } from '@/shared/api/http/types/@shared'; +import { useEnterPartyroom } from './use-enter-partyroom.mutation'; + +describe('Partyroom mutation integration (hook → service → MSW)', () => { + describe('useEnterPartyroom', () => { + it('returns crewId and gradeType on success', async () => { + const { result } = renderWithClient(() => useEnterPartyroom()); + + result.current.mutate({ partyroomId: 1 }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + crewId: 99, + gradeType: 'CLUBBER', + }); + }); + + it('propagates error and emits errorCode on API failure', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/crews', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '이미 다른 파티룸에 활성화되어 있음', + errorCode: ErrorCode.ACTIVE_ANOTHER_ROOM, + }, + }, + { status: 400 } + ); + }) + ); + + const emitted: string[] = []; + const unsub = errorEmitter.on(ErrorCode.ACTIVE_ANOTHER_ROOM, () => + emitted.push(ErrorCode.ACTIVE_ANOTHER_ROOM) + ); + + const { result } = renderWithClient(() => useEnterPartyroom()); + + result.current.mutate({ partyroomId: 999 }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error?.response?.status).toBe(400); + expect(emitted).toContain(ErrorCode.ACTIVE_ANOTHER_ROOM); + + unsub(); + }); + }); + + describe('useCreatePartyroom', () => { + it('returns partyroomId on success', async () => { + const { result } = renderWithClient(() => useCreatePartyroom()); + + result.current.mutate({ + title: 'Test Room', + introduction: 'Hello', + playbackTimeLimit: 300, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ partyroomId: 42 }); + }); + }); + + describe('useAdjustGrade', () => { + it('resolves on success and invalidates Crews cache', async () => { + const { result, queryClient } = renderWithClient(() => useAdjustGrade()); + + queryClient.setQueryData([QueryKeys.Crews], []); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ partyroomId: 1, crewId: 99, gradeType: 'MANAGER' as any }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Crews] }) + ); + }); + }); +}); diff --git a/src/features/partyroom/enter/lib/use-enter-partyroom.test.ts b/src/features/partyroom/enter/lib/use-enter-partyroom.test.ts new file mode 100644 index 00000000..acc6b174 --- /dev/null +++ b/src/features/partyroom/enter/lib/use-enter-partyroom.test.ts @@ -0,0 +1,85 @@ +vi.mock('@/entities/partyroom-client'); +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('@/shared/lib/router/use-app-router.hook'); +vi.mock('../api/use-enter-partyroom.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { + usePartyroomClient, + useHandlePartyroomSubscriptionEvent, +} from '@/entities/partyroom-client'; +import { useAppRouter } from '@/shared/lib/router/use-app-router.hook'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useEnterPartyroom } from './use-enter-partyroom'; +import { useEnterPartyroom as useEnterPartyroomMutation } from '../api/use-enter-partyroom.mutation'; + +const mockOnConnect = vi.fn(); +const mockMutate = vi.fn(); +const mockInit = vi.fn(); +const mockMarkExitedOnBackend = vi.fn(); +const mockPush = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (usePartyroomClient as Mock).mockReturnValue({ + onConnect: mockOnConnect, + subscribe: vi.fn(), + }); + (useHandlePartyroomSubscriptionEvent as Mock).mockReturnValue(vi.fn()); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ init: mockInit, markExitedOnBackend: mockMarkExitedOnBackend }), + }); + (useEnterPartyroomMutation as Mock).mockReturnValue({ mutate: mockMutate }); + (useAppRouter as Mock).mockReturnValue({ push: mockPush }); +}); + +describe('useEnterPartyroom', () => { + test('반환된 함수를 호출하면 client.onConnect를 등록한다', () => { + const { result } = renderHook(() => useEnterPartyroom(1)); + + act(() => { + result.current(); + }); + + expect(mockOnConnect).toHaveBeenCalledWith(expect.any(Function), { once: true }); + }); + + test('onConnect 콜백이 실행되면 enter mutation을 호출한다', () => { + const { result } = renderHook(() => useEnterPartyroom(42)); + + act(() => { + result.current(); + }); + + // onConnect의 첫 번째 인자인 콜백을 실행 + const onConnectCallback = mockOnConnect.mock.calls[0][0]; + onConnectCallback(); + + expect(mockMutate).toHaveBeenCalledWith( + { partyroomId: 42 }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + test('enter 실패 시 markExitedOnBackend 호출 후 로비로 이동한다', () => { + const { result } = renderHook(() => useEnterPartyroom(1)); + + act(() => { + result.current(); + }); + + const onConnectCallback = mockOnConnect.mock.calls[0][0]; + onConnectCallback(); + + // enter mutation의 onError 콜백 실행 + const mutateOptions = mockMutate.mock.calls[0][1]; + mutateOptions.onError(); + + expect(mockMarkExitedOnBackend).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/parties'); + }); +}); diff --git a/src/features/partyroom/enter/lib/use-partyroom-enter-error-alerts.test.ts b/src/features/partyroom/enter/lib/use-partyroom-enter-error-alerts.test.ts new file mode 100644 index 00000000..20f7f104 --- /dev/null +++ b/src/features/partyroom/enter/lib/use-partyroom-enter-error-alerts.test.ts @@ -0,0 +1,52 @@ +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('@/shared/api/http/error/use-on-error.hook'); + +import { renderHook } from '@testing-library/react'; +import useOnError from '@/shared/api/http/error/use-on-error.hook'; +import { ErrorCode } from '@/shared/api/http/types/@shared'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import usePartyroomEnterErrorAlerts from './use-partyroom-enter-error-alerts'; + +const mockOpenAlertDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + partyroom: { ec: { shut_down: 'Room closed' } }, + auth: { para: { auth_quota_exceeded: 'Limit exceeded' } }, + }); + (useDialog as Mock).mockReturnValue({ openAlertDialog: mockOpenAlertDialog }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + (useOnError as Mock).mockImplementation(() => {}); +}); + +describe('usePartyroomEnterErrorAlerts', () => { + test('3개의 에러 코드에 대해 useOnError를 등록한다', () => { + renderHook(() => usePartyroomEnterErrorAlerts()); + + expect(useOnError).toHaveBeenCalledTimes(3); + expect(useOnError).toHaveBeenCalledWith(ErrorCode.NOT_FOUND_ROOM, expect.any(Function)); + expect(useOnError).toHaveBeenCalledWith(ErrorCode.ALREADY_TERMINATED, expect.any(Function)); + expect(useOnError).toHaveBeenCalledWith(ErrorCode.EXCEEDED_LIMIT, expect.any(Function)); + }); + + test('NOT_FOUND_ROOM 콜백이 shut_down 메시지로 alert를 연다', () => { + (useOnError as Mock).mockImplementation((code: ErrorCode, cb: (...args: any[]) => void) => { + if (code === ErrorCode.NOT_FOUND_ROOM) cb(); + }); + + renderHook(() => usePartyroomEnterErrorAlerts()); + expect(mockOpenAlertDialog).toHaveBeenCalledWith({ content: 'Room closed' }); + }); + + test('EXCEEDED_LIMIT 콜백이 auth_quota_exceeded 메시지로 alert를 연다', () => { + (useOnError as Mock).mockImplementation((code: ErrorCode, cb: (...args: any[]) => void) => { + if (code === ErrorCode.EXCEEDED_LIMIT) cb(); + }); + + renderHook(() => usePartyroomEnterErrorAlerts()); + expect(mockOpenAlertDialog).toHaveBeenCalledWith({ content: 'Limit exceeded' }); + }); +}); diff --git a/src/features/partyroom/evaluate-current-playback/api/use-evaluate-current-playback.integration.test.ts b/src/features/partyroom/evaluate-current-playback/api/use-evaluate-current-playback.integration.test.ts new file mode 100644 index 00000000..b5a049b1 --- /dev/null +++ b/src/features/partyroom/evaluate-current-playback/api/use-evaluate-current-playback.integration.test.ts @@ -0,0 +1,45 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useEvaluateCurrentPlayback } from './use-evaluate-current-playback.mutation'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useEvaluateCurrentPlayback 통합', () => { + test('LIKE 반응 성공 시 결과를 반환한다', async () => { + const { result } = renderWithClient(() => useEvaluateCurrentPlayback()); + + await act(async () => { + result.current.mutate('LIKE' as any); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + isLiked: true, + isDisliked: false, + isGrabbed: false, + }); + }); + + test('partyroomId가 없으면 에러를 던진다', async () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: undefined }), + }); + + const { result } = renderWithClient(() => useEvaluateCurrentPlayback()); + + await act(async () => { + result.current.mutate('LIKE' as any); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/src/features/partyroom/exit/api/use-exit-partyroom.integration.test.ts b/src/features/partyroom/exit/api/use-exit-partyroom.integration.test.ts new file mode 100644 index 00000000..ee9bd123 --- /dev/null +++ b/src/features/partyroom/exit/api/use-exit-partyroom.integration.test.ts @@ -0,0 +1,49 @@ +vi.mock('@/entities/current-partyroom', () => ({ + useRemoveCurrentPartyroomCaches: () => mockRemoveCaches, +})); + +const mockRemoveCaches = vi.fn(); + +import { act, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { ErrorCode } from '@/shared/api/http/types/@shared'; +import { useExitPartyroom } from './use-exit-partyroom.mutation'; + +const API = process.env.NEXT_PUBLIC_API_HOST_NAME; + +beforeEach(() => vi.clearAllMocks()); + +describe('useExitPartyroom 통합', () => { + test('성공 시 removeCurrentPartyroomCaches를 호출한다', async () => { + const { result } = renderWithClient(() => useExitPartyroom()); + + await act(async () => { + result.current.mutate({ partyroomId: 1 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockRemoveCaches).toHaveBeenCalledTimes(1); + }); + + test('API 에러 시 isError가 true가 된다', async () => { + server.use( + http.delete(`${API}v1/partyrooms/:id/crews/me`, () => + HttpResponse.json( + { errorCode: ErrorCode.ACTIVE_ANOTHER_ROOM, reason: 'error' }, + { status: 400 } + ) + ) + ); + + const { result } = renderWithClient(() => useExitPartyroom()); + + await act(async () => { + result.current.mutate({ partyroomId: 1 }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mockRemoveCaches).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/exit/lib/use-exit-partyroom.test.ts b/src/features/partyroom/exit/lib/use-exit-partyroom.test.ts new file mode 100644 index 00000000..3bc65b41 --- /dev/null +++ b/src/features/partyroom/exit/lib/use-exit-partyroom.test.ts @@ -0,0 +1,56 @@ +vi.mock('@/entities/partyroom-client'); +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('../api/use-exit-partyroom.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { usePartyroomClient } from '@/entities/partyroom-client'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useExitPartyroom } from './use-exit-partyroom'; +import { useExitPartyroom as useExitPartyroomMutation } from '../api/use-exit-partyroom.mutation'; + +const mockMutate = vi.fn(); +const mockUnsubscribe = vi.fn(); +const mockReset = vi.fn(); +const mockGetState = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (usePartyroomClient as Mock).mockReturnValue({ + unsubscribeCurrentRoom: mockUnsubscribe, + }); + mockGetState.mockReturnValue({ exitedOnBackend: false }); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: Object.assign( + (selector: (...args: any[]) => any) => selector({ reset: mockReset }), + { getState: mockGetState } + ), + }); + (useExitPartyroomMutation as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useExitPartyroom (lib)', () => { + test('exitedOnBackend=false이면 exit mutation을 호출한다', () => { + const { result } = renderHook(() => useExitPartyroom(1)); + + act(() => { + result.current(); + }); + + expect(mockMutate).toHaveBeenCalledWith({ partyroomId: 1 }); + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalled(); + }); + + test('exitedOnBackend=true이면 exit mutation을 호출하지 않는다', () => { + mockGetState.mockReturnValue({ exitedOnBackend: true }); + const { result } = renderHook(() => useExitPartyroom(1)); + + act(() => { + result.current(); + }); + + expect(mockMutate).not.toHaveBeenCalled(); + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/impose-penalty/api/penalty.integration.test.ts b/src/features/partyroom/impose-penalty/api/penalty.integration.test.ts new file mode 100644 index 00000000..06b24ffa --- /dev/null +++ b/src/features/partyroom/impose-penalty/api/penalty.integration.test.ts @@ -0,0 +1,43 @@ +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import useImposePenaltyMutation from './use-impose-penalty.mutation'; +import useLiftPenalty from '../../lift-penalty/api/use-lift-penalty.mutation'; + +describe('useImposePenaltyMutation 통합', () => { + test('성공 시 Penalties 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useImposePenaltyMutation()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ + partyroomId: 1, + crewId: 10, + penaltyType: 'MUTE' as any, + detail: 'test', + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.Penalties], + }); + }); +}); + +describe('useLiftPenalty 통합', () => { + test('성공 시 Penalties 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useLiftPenalty()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ partyroomId: 1, penaltyId: 1 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.Penalties], + }); + }); +}); diff --git a/src/features/partyroom/impose-penalty/api/use-can-impose-penalty.hook.test.ts b/src/features/partyroom/impose-penalty/api/use-can-impose-penalty.hook.test.ts new file mode 100644 index 00000000..f539216e --- /dev/null +++ b/src/features/partyroom/impose-penalty/api/use-can-impose-penalty.hook.test.ts @@ -0,0 +1,64 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanImposePenalty from './use-can-impose-penalty.hook'; +import useCanRemoveChatMessage from './use-can-remove-chat-message.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanImposePenalty', () => { + test('me가 없으면 항상 false를 반환한다', () => { + const { result } = renderHook(() => useCanImposePenalty()); + expect(result.current(GradeType.CLUBBER)).toBe(false); + }); + + test('MODERATOR는 하위 등급에 패널티를 부과할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanImposePenalty()); + + expect(result.current(GradeType.CLUBBER)).toBe(true); + expect(result.current(GradeType.LISTENER)).toBe(true); + }); + + test('MODERATOR는 동등 이상 등급에 패널티를 부과할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanImposePenalty()); + + expect(result.current(GradeType.MODERATOR)).toBe(false); + expect(result.current(GradeType.HOST)).toBe(false); + }); + + test('CLUBBER는 패널티 부과 권한이 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanImposePenalty()); + expect(result.current(GradeType.LISTENER)).toBe(false); + }); +}); + +describe('useCanRemoveChatMessage', () => { + test('me가 없으면 항상 false를 반환한다', () => { + const { result } = renderHook(() => useCanRemoveChatMessage()); + expect(result.current(GradeType.CLUBBER)).toBe(false); + }); + + test('MODERATOR는 하위 등급의 채팅을 삭제할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanRemoveChatMessage()); + expect(result.current(GradeType.CLUBBER)).toBe(true); + }); + + test('CLUBBER는 채팅 삭제 권한이 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanRemoveChatMessage()); + expect(result.current(GradeType.LISTENER)).toBe(false); + }); +}); diff --git a/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.test.tsx b/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.test.tsx new file mode 100644 index 00000000..21af6430 --- /dev/null +++ b/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.test.tsx @@ -0,0 +1,76 @@ +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('../api/use-can-impose-penalty.hook'); + +import { renderHook } from '@testing-library/react'; +import { GradeType, PenaltyType } from '@/shared/api/http/types/@enums'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useImposePenalty from './use-impose-penalty.hook'; +import useCanImposePenalty from '../api/use-can-impose-penalty.hook'; + +const mockOpenDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + chat: { para: { reason_shared: 'Enter reason' } }, + common: { btn: { cancel: 'Cancel', confirm: 'Confirm' } }, + }); + (useDialog as Mock).mockReturnValue({ openDialog: mockOpenDialog }); +}); + +describe('useImposePenalty', () => { + test('partyroomId가 없으면 다이얼로그를 열지 않는다', () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: null }), + }); + (useCanImposePenalty as Mock).mockReturnValue(() => true); + + const { result } = renderHook(() => useImposePenalty()); + result.current({ + crewId: 1, + nickname: 'User', + crewGradeType: GradeType.CLUBBER, + penaltyType: PenaltyType.CHAT_BAN_30_SECONDS, + }); + + expect(mockOpenDialog).not.toHaveBeenCalled(); + }); + + test('권한이 없으면 다이얼로그를 열지 않는다', () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); + (useCanImposePenalty as Mock).mockReturnValue(() => false); + + const { result } = renderHook(() => useImposePenalty()); + result.current({ + crewId: 1, + nickname: 'User', + crewGradeType: GradeType.CLUBBER, + penaltyType: PenaltyType.CHAT_BAN_30_SECONDS, + }); + + expect(mockOpenDialog).not.toHaveBeenCalled(); + }); + + test('권한이 있으면 다이얼로그를 연다', () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); + (useCanImposePenalty as Mock).mockReturnValue(() => true); + + const { result } = renderHook(() => useImposePenalty()); + result.current({ + crewId: 1, + nickname: 'User', + crewGradeType: GradeType.CLUBBER, + penaltyType: PenaltyType.CHAT_BAN_30_SECONDS, + }); + + expect(mockOpenDialog).toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.tsx b/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.tsx index 693337c2..97acb294 100644 --- a/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.tsx +++ b/src/features/partyroom/impose-penalty/lib/use-impose-penalty.hook.tsx @@ -2,7 +2,8 @@ import { ReactNode, useState } from 'react'; import { GradeType, PenaltyType } from '@/shared/api/http/types/@enums'; import { PartyroomCrew } from '@/shared/api/http/types/partyrooms'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { Trans, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { useStores } from '@/shared/lib/store/stores.context'; import { Dialog, useDialog } from '@/shared/ui/components/dialog'; import { Input } from '@/shared/ui/components/input'; diff --git a/src/features/partyroom/lift-penalty/api/use-can-lift-penalty.hook.test.ts b/src/features/partyroom/lift-penalty/api/use-can-lift-penalty.hook.test.ts new file mode 100644 index 00000000..ae8dbc86 --- /dev/null +++ b/src/features/partyroom/lift-penalty/api/use-can-lift-penalty.hook.test.ts @@ -0,0 +1,36 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanLiftPenalty from './use-can-lift-penalty.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanLiftPenalty', () => { + test('me가 없으면 항상 false를 반환한다', () => { + const { result } = renderHook(() => useCanLiftPenalty()); + expect(result.current(GradeType.CLUBBER)).toBe(false); + }); + + test('HOST는 하위 등급의 패널티를 해제할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => useCanLiftPenalty()); + + expect(result.current(GradeType.MODERATOR)).toBe(true); + expect(result.current(GradeType.CLUBBER)).toBe(true); + }); + + test('CLUBBER는 패널티 해제 권한이 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanLiftPenalty()); + expect(result.current(GradeType.LISTENER)).toBe(false); + }); +}); diff --git a/src/features/partyroom/list-crews/lib/use-crews.hook.test.ts b/src/features/partyroom/list-crews/lib/use-crews.hook.test.ts new file mode 100644 index 00000000..4bf19754 --- /dev/null +++ b/src/features/partyroom/list-crews/lib/use-crews.hook.test.ts @@ -0,0 +1,46 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCrews from './use-crews.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +const createCrew = (id: number) => ({ + crewId: id, + nickname: `Crew${id}`, + gradeType: GradeType.CLUBBER, + avatarBodyUri: '', + avatarFaceUri: '', + avatarIconUri: '', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, +}); + +describe('useCrews', () => { + test('초기값은 빈 배열이다', () => { + const { result } = renderHook(() => useCrews()); + expect(result.current).toEqual([]); + }); + + test('스토어에 crew가 있으면 해당 목록을 반환한다', () => { + store.setState({ crews: [createCrew(1), createCrew(2)] }); + + const { result } = renderHook(() => useCrews()); + expect(result.current).toHaveLength(2); + expect(result.current[0].crewId).toBe(1); + expect(result.current[1].crewId).toBe(2); + }); +}); diff --git a/src/features/partyroom/list-crews/model/crews.model.test.ts b/src/features/partyroom/list-crews/model/crews.model.test.ts new file mode 100644 index 00000000..ddf907e2 --- /dev/null +++ b/src/features/partyroom/list-crews/model/crews.model.test.ts @@ -0,0 +1,72 @@ +import type { Model } from '@/entities/current-partyroom/model/crew.model'; +import { GradeType, MotionType } from '@/shared/api/http/types/@enums'; + +// barrel export에 JSX 포함된 훅이 있어 직접 모듈 경로로 모킹 +vi.mock('@/entities/current-partyroom', async () => ({ + Crew: await vi.importActual('@/entities/current-partyroom/model/crew.model'), +})); + +import { categorizeByGradeType } from './crews.model'; + +const createCrew = (overrides: Partial = {}): Model => ({ + crewId: 1, + nickname: 'tester', + gradeType: GradeType.CLUBBER, + avatarBodyUri: '', + avatarFaceUri: '', + avatarIconUri: '', + combinePositionX: 0, + combinePositionY: 0, + offsetX: 0, + offsetY: 0, + scale: 1, + motionType: MotionType.NONE, + ...overrides, +}); + +describe('crews model', () => { + describe('categorizeByGradeType', () => { + test('등급별로 크루를 분류', () => { + const crews = [ + createCrew({ crewId: 1, gradeType: GradeType.HOST }), + createCrew({ crewId: 2, gradeType: GradeType.CLUBBER }), + createCrew({ crewId: 3, gradeType: GradeType.CLUBBER }), + createCrew({ crewId: 4, gradeType: GradeType.LISTENER }), + ]; + + const result = categorizeByGradeType(crews); + + expect(result[GradeType.HOST]).toHaveLength(1); + expect(result[GradeType.CLUBBER]).toHaveLength(2); + expect(result[GradeType.LISTENER]).toHaveLength(1); + }); + + test('해당 등급의 크루가 없으면 키가 없음', () => { + const crews = [createCrew({ gradeType: GradeType.HOST })]; + + const result = categorizeByGradeType(crews); + + expect(result[GradeType.HOST]).toHaveLength(1); + expect(result[GradeType.MODERATOR]).toBeUndefined(); + expect(result[GradeType.CLUBBER]).toBeUndefined(); + }); + + test('빈 배열이면 빈 객체 반환', () => { + expect(categorizeByGradeType([])).toEqual({}); + }); + + test('gradePriorities 순서에 따라 키 순서 보장', () => { + const crews = [ + createCrew({ crewId: 1, gradeType: GradeType.LISTENER }), + createCrew({ crewId: 2, gradeType: GradeType.HOST }), + createCrew({ crewId: 3, gradeType: GradeType.MODERATOR }), + ]; + + const result = categorizeByGradeType(crews); + const keys = Object.keys(result); + + expect(keys.indexOf(GradeType.HOST)).toBeLessThan(keys.indexOf(GradeType.MODERATOR)); + expect(keys.indexOf(GradeType.MODERATOR)).toBeLessThan(keys.indexOf(GradeType.LISTENER)); + }); + }); +}); diff --git a/src/features/partyroom/list-djing-queue/api/dj-queries.integration.test.ts b/src/features/partyroom/list-djing-queue/api/dj-queries.integration.test.ts new file mode 100644 index 00000000..82247e25 --- /dev/null +++ b/src/features/partyroom/list-djing-queue/api/dj-queries.integration.test.ts @@ -0,0 +1,40 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useFetchDjingQueue from './use-fetch-djing-queue.query'; +import useFetchPlaybackHistory from '../../list-playback-histories/api/use-fetch-playback-histories.query'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useFetchDjingQueue 통합', () => { + test('DJ 대기열 정보를 반환한다', async () => { + const { result } = renderWithClient(() => useFetchDjingQueue({ partyroomId: 1 })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveProperty('queueStatus', 'OPEN'); + expect(result.current.data?.djs).toHaveLength(2); + }); + + test('enabled=false이면 쿼리가 실행되지 않는다', () => { + const { result } = renderWithClient(() => useFetchDjingQueue({ partyroomId: 1 }, false)); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); + +describe('useFetchPlaybackHistory 통합', () => { + test('재생 이력을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchPlaybackHistory()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0]).toHaveProperty('musicName', 'Song A'); + }); +}); diff --git a/src/features/partyroom/list-my-blocked-crews/api/use-fetch-my-blocked-crews.integration.test.ts b/src/features/partyroom/list-my-blocked-crews/api/use-fetch-my-blocked-crews.integration.test.ts new file mode 100644 index 00000000..46bfe7f6 --- /dev/null +++ b/src/features/partyroom/list-my-blocked-crews/api/use-fetch-my-blocked-crews.integration.test.ts @@ -0,0 +1,19 @@ +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import useFetchMyBlockedCrews from './use-fetch-my-blocked-crews.query'; + +describe('useFetchMyBlockedCrews 통합', () => { + test('차단된 크루 목록을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchMyBlockedCrews()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data?.[0]).toHaveProperty('nickname', 'BlockedUser'); + }); + + test('enabled=false이면 쿼리가 실행되지 않는다', () => { + const { result } = renderWithClient(() => useFetchMyBlockedCrews(false)); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); diff --git a/src/features/partyroom/list-my-blocked-crews/lib/use-is-blocked-crew.hook.test.ts b/src/features/partyroom/list-my-blocked-crews/lib/use-is-blocked-crew.hook.test.ts new file mode 100644 index 00000000..f2804c23 --- /dev/null +++ b/src/features/partyroom/list-my-blocked-crews/lib/use-is-blocked-crew.hook.test.ts @@ -0,0 +1,45 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('../api/use-fetch-my-blocked-crews.query'); + +import { renderHook } from '@testing-library/react'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useIsBlockedCrew from './use-is-blocked-crew.hook'; +import useFetchMyBlockedCrews from '../api/use-fetch-my-blocked-crews.query'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useIsBlockedCrew hook', () => { + test('차단된 crew에 대해 true를 반환한다', () => { + (useFetchMyBlockedCrews as Mock).mockReturnValue({ + data: [{ blockedCrewId: 10 }, { blockedCrewId: 20 }], + }); + + const { result } = renderHook(() => useIsBlockedCrew()); + expect(result.current(10)).toBe(true); + expect(result.current(20)).toBe(true); + }); + + test('차단되지 않은 crew에 대해 false를 반환한다', () => { + (useFetchMyBlockedCrews as Mock).mockReturnValue({ + data: [{ blockedCrewId: 10 }], + }); + + const { result } = renderHook(() => useIsBlockedCrew()); + expect(result.current(99)).toBe(false); + }); + + test('partyroomId가 없으면 빈 Set을 사용한다', () => { + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: undefined }), + }); + (useFetchMyBlockedCrews as Mock).mockReturnValue({ data: [] }); + + const { result } = renderHook(() => useIsBlockedCrew()); + expect(result.current(10)).toBe(false); + }); +}); diff --git a/src/features/partyroom/list-penalties/api/use-can-view-penalties.hook.test.ts b/src/features/partyroom/list-penalties/api/use-can-view-penalties.hook.test.ts new file mode 100644 index 00000000..2e081009 --- /dev/null +++ b/src/features/partyroom/list-penalties/api/use-can-view-penalties.hook.test.ts @@ -0,0 +1,40 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import canViewPenalties from './use-can-view-penalties.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('canViewPenalties', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => canViewPenalties()); + expect(result.current).toBe(false); + }); + + test('MODERATOR는 패널티를 조회할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => canViewPenalties()); + expect(result.current).toBe(true); + }); + + test('HOST는 패널티를 조회할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.HOST } as any }); + const { result } = renderHook(() => canViewPenalties()); + expect(result.current).toBe(true); + }); + + test('CLUBBER는 패널티를 조회할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => canViewPenalties()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/list-penalties/api/use-fetch-penalties.integration.test.ts b/src/features/partyroom/list-penalties/api/use-fetch-penalties.integration.test.ts new file mode 100644 index 00000000..1174a555 --- /dev/null +++ b/src/features/partyroom/list-penalties/api/use-fetch-penalties.integration.test.ts @@ -0,0 +1,39 @@ +vi.mock('@/shared/lib/store/stores.context'); +vi.mock('./use-can-view-penalties.hook'); + +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useStores } from '@/shared/lib/store/stores.context'; +import canViewPenalties from './use-can-view-penalties.hook'; +import useFetchPenalties from './use-fetch-penalties.query'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); + (canViewPenalties as Mock).mockReturnValue(true); +}); + +describe('useFetchPenalties 통합', () => { + test('패널티 목록을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchPenalties()); + + // initialData가 []이므로 실제 fetch 완료까지 대기 + await waitFor(() => expect(result.current.data?.length).toBeGreaterThan(0)); + expect(result.current.data).toEqual( + expect.arrayContaining([expect.objectContaining({ penaltyId: expect.any(Number) })]) + ); + }); + + test('canView가 false이면 쿼리가 실행되지 않는다', async () => { + (canViewPenalties as Mock).mockReturnValue(false); + + const { result } = renderWithClient(() => useFetchPenalties()); + + await waitFor(() => expect(result.current.fetchStatus).toBe('idle')); + // initialData가 []이므로 data는 빈 배열 + expect(result.current.data).toEqual([]); + }); +}); diff --git a/src/features/partyroom/list/api/partyroom-queries.integration.test.ts b/src/features/partyroom/list/api/partyroom-queries.integration.test.ts new file mode 100644 index 00000000..1a30be7c --- /dev/null +++ b/src/features/partyroom/list/api/partyroom-queries.integration.test.ts @@ -0,0 +1,30 @@ +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useFetchGeneralPartyrooms } from './use-fetch-general-partyrooms.query'; +import useFetchPartyroomDetailSummary from '../../get-summary/api/use-fetch-partyroom-detail-summary.query'; + +describe('useFetchGeneralPartyrooms 통합', () => { + test('GENERAL 타입의 파티룸만 반환한다', async () => { + const { result } = renderWithClient(() => useFetchGeneralPartyrooms()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // 핸들러가 MAIN 타입만 반환하므로 GENERAL 필터링 결과는 빈 배열 + expect(result.current.data).toEqual([]); + }); +}); + +describe('useFetchPartyroomDetailSummary 통합', () => { + test('partyroomId로 상세 정보를 조회한다', async () => { + const { result } = renderWithClient(() => useFetchPartyroomDetailSummary(1, true)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveProperty('title', 'Main Stage'); + expect(result.current.data).toHaveProperty('linkDomain', 'main-stage'); + }); + + test('enabled=false이면 쿼리가 실행되지 않는다', () => { + const { result } = renderWithClient(() => useFetchPartyroomDetailSummary(1, false)); + expect(result.current.fetchStatus).toBe('idle'); + }); +}); diff --git a/src/features/partyroom/lock-djing-queue/api/dj-management.integration.test.ts b/src/features/partyroom/lock-djing-queue/api/dj-management.integration.test.ts new file mode 100644 index 00000000..f57eb19c --- /dev/null +++ b/src/features/partyroom/lock-djing-queue/api/dj-management.integration.test.ts @@ -0,0 +1,82 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useLockDjingQueue from './use-lock-djing-queue.mutation'; +import useDeleteDjFromQueueMutation from '../../delete-dj-from-queue/api/use-delete-dj-from-queue.mutation'; +import { useSkipPlayback } from '../../skip-playback/api/use-skip-playback.mutation'; +import useUnlockDjingQueue from '../../unlock-djing-queue/api/use-unlock-djing-queue.mutation'; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => selector({ id: 1 }), + }); +}); + +describe('useLockDjingQueue 통합', () => { + test('성공 시 DjingQueue 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useLockDjingQueue()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.DjingQueue, 1], + }); + }); +}); + +describe('useUnlockDjingQueue 통합', () => { + test('성공 시 DjingQueue 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useUnlockDjingQueue()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.DjingQueue, 1], + }); + }); +}); + +describe('useDeleteDjFromQueueMutation 통합', () => { + test('성공 시 DjingQueue 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useDeleteDjFromQueueMutation()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate('dj-123'); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.DjingQueue, 1], + }); + }); +}); + +describe('useSkipPlayback 통합', () => { + test('성공 시 DjingQueue 캐시를 무효화한다', async () => { + const { result, queryClient } = renderWithClient(() => useSkipPlayback()); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await act(async () => { + result.current.mutate({ partyroomId: 1 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.DjingQueue, 1], + }); + }); +}); diff --git a/src/features/partyroom/lock-djing-queue/lib/use-can-lock-djing-queue.hook.test.ts b/src/features/partyroom/lock-djing-queue/lib/use-can-lock-djing-queue.hook.test.ts new file mode 100644 index 00000000..2d9fcc45 --- /dev/null +++ b/src/features/partyroom/lock-djing-queue/lib/use-can-lock-djing-queue.hook.test.ts @@ -0,0 +1,34 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanLockDjingQueue from './use-can-lock-djing-queue.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanLockDjingQueue', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => useCanLockDjingQueue()); + expect(result.current).toBe(false); + }); + + test('MODERATOR는 대기열을 잠글 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanLockDjingQueue()); + expect(result.current).toBe(true); + }); + + test('CLUBBER는 대기열을 잠글 수 없다', () => { + store.setState({ me: { gradeType: GradeType.CLUBBER } as any }); + const { result } = renderHook(() => useCanLockDjingQueue()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/lock-djing-queue/lib/use-lock-djing-queue.hook.test.ts b/src/features/partyroom/lock-djing-queue/lib/use-lock-djing-queue.hook.test.ts new file mode 100644 index 00000000..08a45664 --- /dev/null +++ b/src/features/partyroom/lock-djing-queue/lib/use-lock-djing-queue.hook.test.ts @@ -0,0 +1,60 @@ +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('./use-can-lock-djing-queue.hook'); +vi.mock('../api/use-lock-djing-queue.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useCanLockDjingQueue from './use-can-lock-djing-queue.hook'; +import useLockDjingQueueHook from './use-lock-djing-queue.hook'; +import useLockDjingQueueMutation from '../api/use-lock-djing-queue.mutation'; + +const mockMutate = vi.fn(); +const mockOpenConfirmDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ dj: { para: { lock_dj_queue: 'Lock?' } } }); + (useDialog as Mock).mockReturnValue({ openConfirmDialog: mockOpenConfirmDialog }); + (useLockDjingQueueMutation as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useLockDjingQueue hook', () => { + test('권한이 없으면 dialog를 열지 않는다', async () => { + (useCanLockDjingQueue as Mock).mockReturnValue(false); + const { result } = renderHook(() => useLockDjingQueueHook()); + + await act(async () => { + await result.current(); + }); + + expect(mockOpenConfirmDialog).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('확인하면 mutate를 호출한다', async () => { + (useCanLockDjingQueue as Mock).mockReturnValue(true); + mockOpenConfirmDialog.mockResolvedValue(true); + const { result } = renderHook(() => useLockDjingQueueHook()); + + await act(async () => { + await result.current(); + }); + + expect(mockOpenConfirmDialog).toHaveBeenCalledWith({ content: 'Lock?' }); + expect(mockMutate).toHaveBeenCalled(); + }); + + test('취소하면 mutate를 호출하지 않는다', async () => { + (useCanLockDjingQueue as Mock).mockReturnValue(true); + mockOpenConfirmDialog.mockResolvedValue(false); + const { result } = renderHook(() => useLockDjingQueueHook()); + + await act(async () => { + await result.current(); + }); + + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/partyroom/select-playlist-for-djing/ui/use-select-playlist.hook.tsx b/src/features/partyroom/select-playlist-for-djing/ui/use-select-playlist.hook.tsx index 5c5c8145..31091e12 100644 --- a/src/features/partyroom/select-playlist-for-djing/ui/use-select-playlist.hook.tsx +++ b/src/features/partyroom/select-playlist-for-djing/ui/use-select-playlist.hook.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { Playlist } from '@/shared/api/http/types/playlists'; import useDidMountEffect from '@/shared/lib/hooks/use-did-mount-effect'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { Trans, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { VariableProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { useStores } from '@/shared/lib/store/stores.context'; import { useDialog } from '@/shared/ui/components/dialog'; import Dialog from '@/shared/ui/components/dialog/dialog.component'; diff --git a/src/features/partyroom/unlock-djing-queue/lib/use-can-unlock-djing-queue.hook.test.ts b/src/features/partyroom/unlock-djing-queue/lib/use-can-unlock-djing-queue.hook.test.ts new file mode 100644 index 00000000..fedbcb31 --- /dev/null +++ b/src/features/partyroom/unlock-djing-queue/lib/use-can-unlock-djing-queue.hook.test.ts @@ -0,0 +1,34 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import { renderHook } from '@testing-library/react'; +import { createCurrentPartyroomStore } from '@/entities/current-partyroom/model/current-partyroom.store'; +import { GradeType } from '@/shared/api/http/types/@enums'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useCanUnlockDjingQueue from './use-can-unlock-djing-queue.hook'; + +let store: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + store = createCurrentPartyroomStore(); + (useStores as Mock).mockReturnValue({ useCurrentPartyroom: store }); +}); + +describe('useCanUnlockDjingQueue', () => { + test('me가 없으면 false를 반환한다', () => { + const { result } = renderHook(() => useCanUnlockDjingQueue()); + expect(result.current).toBe(false); + }); + + test('MODERATOR는 대기열 잠금을 해제할 수 있다', () => { + store.setState({ me: { gradeType: GradeType.MODERATOR } as any }); + const { result } = renderHook(() => useCanUnlockDjingQueue()); + expect(result.current).toBe(true); + }); + + test('LISTENER는 대기열 잠금을 해제할 수 없다', () => { + store.setState({ me: { gradeType: GradeType.LISTENER } as any }); + const { result } = renderHook(() => useCanUnlockDjingQueue()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/features/partyroom/unlock-djing-queue/lib/use-unlock-djing-queue.hook.test.ts b/src/features/partyroom/unlock-djing-queue/lib/use-unlock-djing-queue.hook.test.ts new file mode 100644 index 00000000..538f4e6a --- /dev/null +++ b/src/features/partyroom/unlock-djing-queue/lib/use-unlock-djing-queue.hook.test.ts @@ -0,0 +1,38 @@ +vi.mock('./use-can-unlock-djing-queue.hook'); +vi.mock('../api/use-unlock-djing-queue.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import useCanUnlockDjingQueue from './use-can-unlock-djing-queue.hook'; +import useUnlockDjingQueueHook from './use-unlock-djing-queue.hook'; +import useUnlockDjingQueueMutation from '../api/use-unlock-djing-queue.mutation'; + +const mockMutate = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useUnlockDjingQueueMutation as Mock).mockReturnValue({ mutate: mockMutate }); +}); + +describe('useUnlockDjingQueue hook', () => { + test('권한이 없으면 mutate를 호출하지 않는다', async () => { + (useCanUnlockDjingQueue as Mock).mockReturnValue(false); + const { result } = renderHook(() => useUnlockDjingQueueHook()); + + await act(async () => { + await result.current(); + }); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + test('권한이 있으면 mutate를 호출한다', async () => { + (useCanUnlockDjingQueue as Mock).mockReturnValue(true); + const { result } = renderHook(() => useUnlockDjingQueueHook()); + + await act(async () => { + await result.current(); + }); + + expect(mockMutate).toHaveBeenCalled(); + }); +}); diff --git a/src/features/playlist/add-tracks/api/use-search-musics.integration.test.ts b/src/features/playlist/add-tracks/api/use-search-musics.integration.test.ts new file mode 100644 index 00000000..af38fafa --- /dev/null +++ b/src/features/playlist/add-tracks/api/use-search-musics.integration.test.ts @@ -0,0 +1,80 @@ +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useSearchMusics } from './use-search-musics.query'; + +describe('useSearchMusics integration (query → service → MSW)', () => { + it('returns search results for a given keyword', async () => { + const { result } = renderWithClient( + (props: { search: string }) => useSearchMusics(props.search), + { + initialProps: { search: 'lofi' }, + } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const data = result.current.data; + expect(data).toHaveLength(2); + expect(data?.[0].videoTitle).toBe('lofi - Result 1'); + expect(data?.[1].videoId).toBe('def456'); + }); + + it('does not fire query when search is empty (enabled: false)', async () => { + const { result } = renderWithClient( + (props: { search: string }) => useSearchMusics(props.search), + { + initialProps: { search: '' }, + } + ); + + // fetchStatus stays idle because the query is disabled + await waitFor(() => expect(result.current.fetchStatus).toBe('idle')); + expect(result.current.data).toBeUndefined(); + }); + + it('uses cached results for the same search term', async () => { + const { result, queryClient } = renderWithClient( + (props: { search: string }) => useSearchMusics(props.search), + { initialProps: { search: 'cached' } } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Cache should contain data for this key + const cached = queryClient.getQueryData([QueryKeys.Musics, 'cached']); + expect(cached).toBeDefined(); + }); + + it('transitions to error state on API failure', async () => { + server.use( + http.get('http://localhost:8080/api/v1/music-search', () => { + return HttpResponse.json( + { + data: { + status: 'INTERNAL_SERVER_ERROR', + code: 500, + message: 'Server Error', + errorCode: 'SYS-001', + }, + }, + { status: 500 } + ); + }) + ); + + const { result } = renderWithClient( + (props: { search: string }) => useSearchMusics(props.search), + { + initialProps: { search: 'fail-query' }, + } + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error?.isAxiosError).toBe(true); + expect(result.current.error?.response?.status).toBe(500); + }); +}); diff --git a/src/features/playlist/add-tracks/ui/search-list-item.component.tsx b/src/features/playlist/add-tracks/ui/search-list-item.component.tsx index d16d29ed..0273da38 100644 --- a/src/features/playlist/add-tracks/ui/search-list-item.component.tsx +++ b/src/features/playlist/add-tracks/ui/search-list-item.component.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; -import { ThumbnailWithPreview, convertSearchMusicToPreview } from '@/entities/music-preview'; +import { convertSearchMusicToPreview } from '@/entities/music-preview'; +import { ThumbnailWithPreview } from '@/entities/music-preview/index.ui'; import { Music } from '@/shared/api/http/types/playlists'; import { safeDecodeURI } from '@/shared/lib/functions/safe-decode-uri'; import { Typography } from '@/shared/ui/components/typography'; diff --git a/src/features/playlist/add/api/use-create-playlist.integration.test.ts b/src/features/playlist/add/api/use-create-playlist.integration.test.ts new file mode 100644 index 00000000..a07bb663 --- /dev/null +++ b/src/features/playlist/add/api/use-create-playlist.integration.test.ts @@ -0,0 +1,85 @@ +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useCreatePlaylist } from './use-create-playlist.mutation'; + +describe('useCreatePlaylist integration (hook → service → MSW)', () => { + it('returns data on successful mutation', async () => { + const { result } = renderWithClient(() => useCreatePlaylist()); + + result.current.mutate({ name: 'My New Playlist' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + id: 1, + orderNumber: 1, + name: 'My New Playlist', + type: 'PLAYLIST', + }); + }); + + it('invalidates Playlist query cache on success', async () => { + const { result, queryClient } = renderWithClient(() => useCreatePlaylist()); + + // Seed the playlist cache + queryClient.setQueryData([QueryKeys.Playlist], { playlists: [] }); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ name: 'Cache Test' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Playlist] }) + ); + }); + + it('propagates AxiosError and emits errorCode on API failure', async () => { + server.use( + http.post('http://localhost:8080/api/v1/playlists', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '재생목록 개수 제한을 초과함', + errorCode: 'PLL-002', + }, + }, + { status: 400 } + ); + }) + ); + + const emitted: string[] = []; + const unsub = errorEmitter.on('PLL-002' as any, () => emitted.push('PLL-002')); + + const { result } = renderWithClient(() => useCreatePlaylist()); + + result.current.mutate({ name: 'Over Limit' }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error?.isAxiosError).toBe(true); + expect(result.current.error?.response?.status).toBe(400); + expect(emitted).toContain('PLL-002'); + + unsub(); + }); + + it('transitions to idle → success after mutation', async () => { + const { result } = renderWithClient(() => useCreatePlaylist()); + + expect(result.current.isIdle).toBe(true); + + result.current.mutate({ name: 'Pending Test' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.isPending).toBe(false); + expect(result.current.data).toBeDefined(); + }); +}); diff --git a/src/features/playlist/add/ui/form.component.tsx b/src/features/playlist/add/ui/form.component.tsx index 172f525f..1e8e9030 100644 --- a/src/features/playlist/add/ui/form.component.tsx +++ b/src/features/playlist/add/ui/form.component.tsx @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { SubmitHandler } from 'react-hook-form'; -import { PlaylistForm, PlaylistFormProps, PlaylistFormValues } from '@/entities/playlist'; -import { ConnectWallet } from '@/entities/wallet'; +import { PlaylistFormValues } from '@/entities/playlist'; +import { PlaylistForm, PlaylistFormProps } from '@/entities/playlist/index.ui'; +import { ConnectWallet } from '@/entities/wallet/index.ui'; import useOnError from '@/shared/api/http/error/use-on-error.hook'; import { ErrorCode } from '@/shared/api/http/types/@shared'; import { useI18n } from '@/shared/lib/localization/i18n.context'; diff --git a/src/features/playlist/djing-guide/ui/guide-1.component.tsx b/src/features/playlist/djing-guide/ui/guide-1.component.tsx index def69039..5ad3a2ec 100644 --- a/src/features/playlist/djing-guide/ui/guide-1.component.tsx +++ b/src/features/playlist/djing-guide/ui/guide-1.component.tsx @@ -1,6 +1,7 @@ import Image from 'next/image'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { BoldProcessor, LineBreakProcessor, Trans } from '@/shared/lib/localization/renderer'; +import { BoldProcessor, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { Typography } from '@/shared/ui/components/typography'; export default function Guide1() { diff --git a/src/features/playlist/djing-guide/ui/guide-2.component.tsx b/src/features/playlist/djing-guide/ui/guide-2.component.tsx index a0de09c5..c4abda39 100644 --- a/src/features/playlist/djing-guide/ui/guide-2.component.tsx +++ b/src/features/playlist/djing-guide/ui/guide-2.component.tsx @@ -1,6 +1,7 @@ import Image from 'next/image'; import { useI18n } from '@/shared/lib/localization/i18n.context'; -import { BoldProcessor, LineBreakProcessor, Trans } from '@/shared/lib/localization/renderer'; +import { BoldProcessor, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { Typography } from '@/shared/ui/components/typography'; export default function Guide2() { diff --git a/src/features/playlist/djing-guide/ui/guide-3.component.tsx b/src/features/playlist/djing-guide/ui/guide-3.component.tsx index 4577c70e..6812b39a 100644 --- a/src/features/playlist/djing-guide/ui/guide-3.component.tsx +++ b/src/features/playlist/djing-guide/ui/guide-3.component.tsx @@ -1,5 +1,6 @@ import Image from 'next/image'; -import { BoldProcessor, LineBreakProcessor, Trans } from '@/shared/lib/localization/renderer'; +import { BoldProcessor, LineBreakProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import { Typography } from '@/shared/ui/components/typography'; export default function Guide3() { diff --git a/src/features/playlist/djing-guide/ui/use-djing-guide.hook.test.ts b/src/features/playlist/djing-guide/ui/use-djing-guide.hook.test.ts new file mode 100644 index 00000000..98ccbb27 --- /dev/null +++ b/src/features/playlist/djing-guide/ui/use-djing-guide.hook.test.ts @@ -0,0 +1,42 @@ +vi.mock('@/entities/preference'); +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); + +import { renderHook, act } from '@testing-library/react'; +import { useUserPreferenceStore } from '@/entities/preference'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useDjingGuide from './use-djing-guide.hook'; + +const mockOpenDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ dj: { title: { rule_guide: 'DJ Guide' } } }); + (useDialog as Mock).mockReturnValue({ openDialog: mockOpenDialog }); +}); + +describe('useDjingGuide hook', () => { + test('djingGuideHidden=false이면 showDjingGuide=true', () => { + (useUserPreferenceStore as unknown as Mock).mockReturnValue(false); + const { result } = renderHook(() => useDjingGuide()); + expect(result.current.showDjingGuide).toBe(true); + }); + + test('djingGuideHidden=true이면 showDjingGuide=false', () => { + (useUserPreferenceStore as unknown as Mock).mockReturnValue(true); + const { result } = renderHook(() => useDjingGuide()); + expect(result.current.showDjingGuide).toBe(false); + }); + + test('openDjingGuideModal이 openDialog를 호출한다', () => { + (useUserPreferenceStore as unknown as Mock).mockReturnValue(false); + const { result } = renderHook(() => useDjingGuide()); + + act(() => { + result.current.openDjingGuideModal(); + }); + + expect(mockOpenDialog).toHaveBeenCalledWith(expect.any(Function)); + }); +}); diff --git a/src/features/playlist/edit/api/playlist-crud.integration.test.ts b/src/features/playlist/edit/api/playlist-crud.integration.test.ts new file mode 100644 index 00000000..274a00af --- /dev/null +++ b/src/features/playlist/edit/api/playlist-crud.integration.test.ts @@ -0,0 +1,91 @@ +import { waitFor } from '@testing-library/react'; +import '@/shared/api/__test__/msw-server'; +import { useAddPlaylistTrack } from '@/features/playlist/add-tracks/api/use-add-playlist-track.mutation'; +import { useFetchPlaylistTracks } from '@/features/playlist/list-tracks/api/use-fetch-playlist-tracks.query'; +import { useRemovePlaylist } from '@/features/playlist/remove/api/use-remove-playlist.mutation'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useUpdatePlaylist } from './use-update-playlist.mutation'; + +describe('Playlist CRUD integration (hook → service → MSW)', () => { + describe('useUpdatePlaylist', () => { + it('returns updated data and invalidates Playlist cache', async () => { + const { result, queryClient } = renderWithClient(() => useUpdatePlaylist()); + + queryClient.setQueryData([QueryKeys.Playlist], { playlists: [] }); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ listId: 1, name: 'Renamed' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(''); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Playlist] }) + ); + }); + }); + + describe('useRemovePlaylist', () => { + it('returns removed IDs and invalidates Playlist cache', async () => { + const { result, queryClient } = renderWithClient(() => useRemovePlaylist()); + + queryClient.setQueryData([QueryKeys.Playlist], { playlists: [] }); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate([1, 2]); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(''); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Playlist] }) + ); + }); + }); + + describe('useAddPlaylistTrack', () => { + it('resolves and invalidates PlaylistTracks + Playlist caches', async () => { + const { result, queryClient } = renderWithClient(() => useAddPlaylistTrack()); + + queryClient.setQueryData([QueryKeys.PlaylistTracks, 5], []); + queryClient.setQueryData([QueryKeys.Playlist], { playlists: [] }); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ + listId: 5, + linkId: 'abc123', + name: 'Test Track', + duration: '03:30', + thumbnailImage: 'https://example.com/thumb.jpg', + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.PlaylistTracks, 5] }) + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Playlist] }) + ); + }); + }); + + describe('useFetchPlaylistTracks', () => { + it('returns paginated track data', async () => { + const { result } = renderWithClient(() => useFetchPlaylistTracks(1)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.content).toHaveLength(2); + expect(result.current.data?.content[0]).toMatchObject({ + trackId: 10, + name: 'Track A', + }); + expect(result.current.data?.pagination).toMatchObject({ + totalElements: 2, + hasNext: false, + }); + }); + }); +}); diff --git a/src/features/playlist/edit/ui/form.component.tsx b/src/features/playlist/edit/ui/form.component.tsx index 01f17cff..d90e08d0 100644 --- a/src/features/playlist/edit/ui/form.component.tsx +++ b/src/features/playlist/edit/ui/form.component.tsx @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { SubmitHandler } from 'react-hook-form'; -import { PlaylistForm, PlaylistFormProps, PlaylistFormValues } from '@/entities/playlist'; +import { PlaylistFormValues } from '@/entities/playlist'; +import { PlaylistForm, PlaylistFormProps } from '@/entities/playlist/index.ui'; import { Playlist } from '@/shared/api/http/types/playlists'; import { useI18n } from '@/shared/lib/localization/i18n.context'; import { useStores } from '@/shared/lib/store/stores.context'; diff --git a/src/features/playlist/list-tracks/ui/track.component.tsx b/src/features/playlist/list-tracks/ui/track.component.tsx index 59de6b9f..89c721a9 100644 --- a/src/features/playlist/list-tracks/ui/track.component.tsx +++ b/src/features/playlist/list-tracks/ui/track.component.tsx @@ -1,7 +1,8 @@ 'use client'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { ThumbnailWithPreview, convertPlaylistTrackToPreview } from '@/entities/music-preview'; +import { convertPlaylistTrackToPreview } from '@/entities/music-preview'; +import { ThumbnailWithPreview } from '@/entities/music-preview/index.ui'; import { PlaylistTrack } from '@/shared/api/http/types/playlists'; import { cn } from '@/shared/lib/functions/cn'; import { IconMenu } from '@/shared/ui/components/icon-menu'; diff --git a/src/features/playlist/list/api/use-fetch-playlists.integration.test.ts b/src/features/playlist/list/api/use-fetch-playlists.integration.test.ts new file mode 100644 index 00000000..1d26c0fc --- /dev/null +++ b/src/features/playlist/list/api/use-fetch-playlists.integration.test.ts @@ -0,0 +1,39 @@ +vi.mock('@/shared/lib/localization/i18n.context'); + +import '@/shared/api/__test__/msw-server'; +import { waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { PlaylistType } from '@/shared/api/http/types/@enums'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import useFetchPlaylists from './use-fetch-playlists.query'; + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + playlist: { title: { grabbed_song: 'Grabbed Songs' } }, + }); +}); + +describe('useFetchPlaylists 통합', () => { + test('플레이리스트 목록을 반환한다', async () => { + const { result } = renderWithClient(() => useFetchPlaylists()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }), + ]) + ); + }); + + test('GRABLIST 타입의 이름을 로컬라이즈된 텍스트로 덮어쓴다', async () => { + const { result } = renderWithClient(() => useFetchPlaylists()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const grablist = result.current.data?.find((p) => p.type === PlaylistType.GRABLIST); + if (grablist) { + expect(grablist.name).toBe('Grabbed Songs'); + } + }); +}); diff --git a/src/features/playlist/list/ui/list.component.tsx b/src/features/playlist/list/ui/list.component.tsx index 9cb1c8ed..7756bc4d 100644 --- a/src/features/playlist/list/ui/list.component.tsx +++ b/src/features/playlist/list/ui/list.component.tsx @@ -1,7 +1,8 @@ 'use client'; import { usePlaylistAction } from '@/entities/playlist'; import { Playlist } from '@/shared/api/http/types/playlists'; -import { Trans, VariableProcessor } from '@/shared/lib/localization/renderer'; +import { VariableProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; import ListItem from './list-item.component'; type Props = { diff --git a/src/features/playlist/move-track-to-playlist/api/use-move-playlist-track.integration.test.ts b/src/features/playlist/move-track-to-playlist/api/use-move-playlist-track.integration.test.ts new file mode 100644 index 00000000..362d35d8 --- /dev/null +++ b/src/features/playlist/move-track-to-playlist/api/use-move-playlist-track.integration.test.ts @@ -0,0 +1,83 @@ +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { useRemovePlaylistTrack } from '@/features/playlist/remove-track/api/use-remove-playlist-track.mutation'; +import { server } from '@/shared/api/__test__/msw-server'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { QueryKeys } from '@/shared/api/http/query-keys'; +import { useMovePlaylistTrack } from './use-move-playlist-track.mutation'; + +describe('Playlist track operations integration (hook → service → MSW)', () => { + describe('useMovePlaylistTrack', () => { + it('resolves on success and invalidates both playlist track caches', async () => { + const { result, queryClient } = renderWithClient(() => useMovePlaylistTrack()); + + queryClient.setQueryData([QueryKeys.PlaylistTracks, 1], []); + queryClient.setQueryData([QueryKeys.PlaylistTracks, 2], []); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ playlistId: 1, trackId: 10, targetPlaylistId: 2 }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.PlaylistTracks, 1] }) + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.PlaylistTracks, 2] }) + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.Playlist] }) + ); + }); + + it('rejects with TRK-001 when track already exists in target', async () => { + server.use( + http.patch('http://localhost:8080/api/v1/playlists/:pid/tracks/:tid/move', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '재생목록에 이미 존재하는 음악', + errorCode: 'TRK-001', + }, + }, + { status: 400 } + ); + }) + ); + + const emitted: string[] = []; + const unsub = errorEmitter.on('TRK-001' as any, () => emitted.push('TRK-001')); + + const { result } = renderWithClient(() => useMovePlaylistTrack()); + + result.current.mutate({ playlistId: 1, trackId: 10, targetPlaylistId: 2 }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.response?.status).toBe(400); + expect(emitted).toContain('TRK-001'); + + unsub(); + }); + }); + + describe('useRemovePlaylistTrack', () => { + it('returns removed track IDs and invalidates caches', async () => { + const { result, queryClient } = renderWithClient(() => useRemovePlaylistTrack()); + + queryClient.setQueryData([QueryKeys.PlaylistTracks, 1], []); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate({ playlistId: 1, trackId: 10 }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(''); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: [QueryKeys.PlaylistTracks, 1] }) + ); + }); + }); +}); diff --git a/src/features/playlist/remove-track/api/use-remove-playlist-track.mutation.ts b/src/features/playlist/remove-track/api/use-remove-playlist-track.mutation.ts index ef734df0..68478b95 100644 --- a/src/features/playlist/remove-track/api/use-remove-playlist-track.mutation.ts +++ b/src/features/playlist/remove-track/api/use-remove-playlist-track.mutation.ts @@ -3,19 +3,12 @@ import { AxiosError } from 'axios'; import { QueryKeys } from '@/shared/api/http/query-keys'; import { playlistsService } from '@/shared/api/http/services'; import { APIError } from '@/shared/api/http/types/@shared'; -import { - RemoveTrackFromPlaylistRequestParams, - RemoveTrackFromPlaylistResponse, -} from '@/shared/api/http/types/playlists'; +import { RemoveTrackFromPlaylistRequestParams } from '@/shared/api/http/types/playlists'; export const useRemovePlaylistTrack = () => { const queryClient = useQueryClient(); - return useMutation< - RemoveTrackFromPlaylistResponse, - AxiosError, - RemoveTrackFromPlaylistRequestParams - >({ + return useMutation, RemoveTrackFromPlaylistRequestParams>({ mutationFn: (request) => playlistsService.removeTrackFromPlaylist(request), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: [QueryKeys.PlaylistTracks, variables.playlistId] }); diff --git a/src/features/sign-in/by-guest/api/use-sign-in.integration.test.ts b/src/features/sign-in/by-guest/api/use-sign-in.integration.test.ts new file mode 100644 index 00000000..a8252c53 --- /dev/null +++ b/src/features/sign-in/by-guest/api/use-sign-in.integration.test.ts @@ -0,0 +1,16 @@ +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import useSignIn from './use-sign-in.mutation'; + +describe('useSignIn (게스트) 통합', () => { + test('게스트 로그인 성공', async () => { + const { result } = renderWithClient(() => useSignIn()); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/src/features/sign-in/by-guest/lib/use-auto-sign-in.hook.ts b/src/features/sign-in/by-guest/lib/use-auto-sign-in.hook.ts index b3bed90c..1b9e8be9 100644 --- a/src/features/sign-in/by-guest/lib/use-auto-sign-in.hook.ts +++ b/src/features/sign-in/by-guest/lib/use-auto-sign-in.hook.ts @@ -2,11 +2,11 @@ import { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import isAuthError from '@/shared/api/http/error/is-auth-error'; import { QueryKeys } from '@/shared/api/http/query-keys'; -import { partyroomsService } from '@/shared/api/http/services'; +import { usersService } from '@/shared/api/http/services'; /** * 비로그인 상태에서 파티룸 직접 접속 시 guest 자동 로그인 수행 - * /v1/partyrooms/link/{partyroomId}/enter 호출로 게스트 세션 생성 + * POST /v1/users/guests/sign 호출로 게스트 세션 생성 */ export default function useAutoSignIn(error: unknown, partyroomId: number | null) { const queryClient = useQueryClient(); @@ -19,9 +19,8 @@ export default function useAutoSignIn(error: unknown, partyroomId: number | null attempted.current = true; setIsSigningIn(true); - partyroomsService - .getPartyroomDetailSummary({ partyroomId }) - .then(({ linkDomain }) => partyroomsService.enterByLink({ linkDomain })) + usersService + .signInGuest() .then(() => queryClient.refetchQueries({ queryKey: [QueryKeys.Me] })) .catch(() => { location.href = '/'; diff --git a/src/features/sign-in/by-social/api/use-callback-login.ts b/src/features/sign-in/by-social/api/use-callback-login.ts index 557e31d4..1cc31307 100644 --- a/src/features/sign-in/by-social/api/use-callback-login.ts +++ b/src/features/sign-in/by-social/api/use-callback-login.ts @@ -15,44 +15,31 @@ export default function useCallbackLogin() { return useMutation, OAuth2Provider>({ mutationKey: ['oauth2-callback'], mutationFn: async (oauth2Provider) => { - try { - const params = parseCallbackParams(); - if (!params.code) { - throw new Error( - params.error_description || params.error || 'Authorization code not received' - ); - } - const { code } = params; - const codeVerifier = getStoredCodeVerifier(); - const state = getStoredState(); - if (!codeVerifier) { - throw new Error('Code verifier not found in storage'); - } - if (!state) { - throw new Error('State not found in storage'); - } - const request = { - provider: oauth2Provider, - code, - codeVerifier, - state, - }; - const response = await usersService.exchangeToken(request); - if (!response.success) { - throw new Error(response.message || 'Token exchange failed'); - } - if (response.success) { - clearStoredCodeVerifier(); - clearStoredState(); - } - return response; - } catch (error) { - console.error('Token exchange failed:', error); - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - }; + const params = parseCallbackParams(); + if (!params.code) { + throw new Error( + params.error_description || params.error || 'Authorization code not received' + ); } + const { code } = params; + const codeVerifier = getStoredCodeVerifier(); + const state = getStoredState(); + if (!codeVerifier) { + throw new Error('Code verifier not found in storage'); + } + if (!state) { + throw new Error('State not found in storage'); + } + const request = { + provider: oauth2Provider, + code, + codeVerifier, + state, + }; + const response = await usersService.exchangeToken(request); + clearStoredCodeVerifier(); + clearStoredState(); + return response; }, }); } diff --git a/src/features/sign-in/by-social/api/use-initiate-login.ts b/src/features/sign-in/by-social/api/use-initiate-login.ts index de95d5a6..44258cb8 100644 --- a/src/features/sign-in/by-social/api/use-initiate-login.ts +++ b/src/features/sign-in/by-social/api/use-initiate-login.ts @@ -12,9 +12,6 @@ export function useInitiateLogin() { provider: oauth2Provider, codeVerifier, }); - if (!response.success) { - throw new Error(response.message || 'Failed to start authentication'); - } setStoredState(response.state); if (!response.authUrl) { throw new Error('Failed to get authentication URL'); diff --git a/src/features/sign-in/by-social/lib/use-social-sign-in-callback.hook.tsx b/src/features/sign-in/by-social/lib/use-social-sign-in-callback.hook.tsx index 0f637284..cb33c1d6 100644 --- a/src/features/sign-in/by-social/lib/use-social-sign-in-callback.hook.tsx +++ b/src/features/sign-in/by-social/lib/use-social-sign-in-callback.hook.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRouter } from 'next/navigation'; // next/router 대신 next/navigation 사용 +import { useRouter } from 'next/navigation'; import { useCallback } from 'react'; import { useGetMyServiceEntry } from '@/entities/me'; import { OAuth2Provider } from '@/shared/api/http/types/users'; @@ -13,11 +13,11 @@ export default function useOAuth2Callback() { return useCallback( async (oauth2Provider: OAuth2Provider) => { - const response = await callbackLogin(oauth2Provider); - if (response.success) { + try { + await callbackLogin(oauth2Provider); const serviceEntry = await getMyServiceEntry(); router.push(serviceEntry); - } else { + } catch { router.push('/sign-in'); } }, diff --git a/src/features/sign-in/by-social/model/pkce-storage.model.test.ts b/src/features/sign-in/by-social/model/pkce-storage.model.test.ts new file mode 100644 index 00000000..70d7077b --- /dev/null +++ b/src/features/sign-in/by-social/model/pkce-storage.model.test.ts @@ -0,0 +1,43 @@ +import { StorageKey, PKCEStorage } from './pkce-storage.model'; + +describe('PKCEStorage', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('setItem 후 getItem으로 저장된 값 반환', () => { + PKCEStorage.setItem(StorageKey.CODE_VERIFIER, 'test-verifier'); + + expect(PKCEStorage.getItem(StorageKey.CODE_VERIFIER)).toBe('test-verifier'); + }); + + test('removeItem 후 getItem은 null 반환', () => { + PKCEStorage.setItem(StorageKey.STATE, 'test-state'); + PKCEStorage.removeItem(StorageKey.STATE); + + expect(PKCEStorage.getItem(StorageKey.STATE)).toBeNull(); + }); + + test('각 StorageKey(CODE_VERIFIER, STATE) 독립적으로 동작', () => { + PKCEStorage.setItem(StorageKey.CODE_VERIFIER, 'verifier-value'); + PKCEStorage.setItem(StorageKey.STATE, 'state-value'); + + expect(PKCEStorage.getItem(StorageKey.CODE_VERIFIER)).toBe('verifier-value'); + expect(PKCEStorage.getItem(StorageKey.STATE)).toBe('state-value'); + }); + + test('서로 다른 키는 독립적으로 저장되어 하나를 삭제해도 다른 키에 영향 없음', () => { + PKCEStorage.setItem(StorageKey.CODE_VERIFIER, 'verifier-value'); + PKCEStorage.setItem(StorageKey.STATE, 'state-value'); + + PKCEStorage.removeItem(StorageKey.CODE_VERIFIER); + + expect(PKCEStorage.getItem(StorageKey.CODE_VERIFIER)).toBeNull(); + expect(PKCEStorage.getItem(StorageKey.STATE)).toBe('state-value'); + }); + + test('저장되지 않은 키를 조회하면 null 반환', () => { + expect(PKCEStorage.getItem(StorageKey.CODE_VERIFIER)).toBeNull(); + expect(PKCEStorage.getItem(StorageKey.STATE)).toBeNull(); + }); +}); diff --git a/src/features/sign-out/api/use-sign-out.integration.test.ts b/src/features/sign-out/api/use-sign-out.integration.test.ts new file mode 100644 index 00000000..ee70ee50 --- /dev/null +++ b/src/features/sign-out/api/use-sign-out.integration.test.ts @@ -0,0 +1,66 @@ +vi.mock('@/shared/lib/store/stores.context'); + +import '@/shared/api/__test__/msw-server'; +import { act, waitFor } from '@testing-library/react'; +import { renderWithClient } from '@/shared/api/__test__/test-utils'; +import { useStores } from '@/shared/lib/store/stores.context'; +import useSignOut from './use-sign-out.mutation'; + +const mockMarkExitedOnBackend = vi.fn(); +let locationHrefSetter: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + (useStores as Mock).mockReturnValue({ + useCurrentPartyroom: (selector: (...args: any[]) => any) => + selector({ markExitedOnBackend: mockMarkExitedOnBackend }), + }); + + // location.href 할당을 가로채서 실제 navigation 방지 + locationHrefSetter = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + origin: originalLocation.origin, + protocol: originalLocation.protocol, + host: originalLocation.host, + hostname: originalLocation.hostname, + port: originalLocation.port, + pathname: originalLocation.pathname, + search: originalLocation.search, + hash: originalLocation.hash, + }, + writable: true, + configurable: true, + }); + Object.defineProperty(window.location, 'href', { + set: locationHrefSetter, + get: () => originalLocation.href, + configurable: true, + }); +}); + +describe('useSignOut 통합', () => { + test('성공 시 markExitedOnBackend를 호출한다', async () => { + const { result } = renderWithClient(() => useSignOut()); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockMarkExitedOnBackend).toHaveBeenCalledTimes(1); + }); + + test('성공 시 location.href를 /로 설정한다', async () => { + const { result } = renderWithClient(() => useSignOut()); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(locationHrefSetter).toHaveBeenCalledWith('/'); + }); +}); diff --git a/src/features/sign-out/lib/use-sign-out.hook.test.ts b/src/features/sign-out/lib/use-sign-out.hook.test.ts new file mode 100644 index 00000000..9cebda9c --- /dev/null +++ b/src/features/sign-out/lib/use-sign-out.hook.test.ts @@ -0,0 +1,51 @@ +vi.mock('@/shared/lib/localization/i18n.context'); +vi.mock('@/shared/ui/components/dialog'); +vi.mock('../api/use-sign-out.mutation'); + +import { renderHook, act } from '@testing-library/react'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useDialog } from '@/shared/ui/components/dialog'; +import useSignOut from './use-sign-out.hook'; +import useSignOutMutation from '../api/use-sign-out.mutation'; + +const mockSignOut = vi.fn(); +const mockOpenConfirmDialog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (useI18n as Mock).mockReturnValue({ + common: { btn: { logout: 'Logout' } }, + auth: { para: { logout_confirm: 'Are you sure?' } }, + }); + (useDialog as Mock).mockReturnValue({ openConfirmDialog: mockOpenConfirmDialog }); + (useSignOutMutation as Mock).mockReturnValue({ mutate: mockSignOut }); +}); + +describe('useSignOut hook', () => { + test('확인하면 signOut을 호출한다', async () => { + mockOpenConfirmDialog.mockResolvedValue(true); + const { result } = renderHook(() => useSignOut()); + + await act(async () => { + await result.current(); + }); + + expect(mockOpenConfirmDialog).toHaveBeenCalledWith({ + title: 'Logout', + content: 'Are you sure?', + okText: 'Logout', + }); + expect(mockSignOut).toHaveBeenCalled(); + }); + + test('취소하면 signOut을 호출하지 않는다', async () => { + mockOpenConfirmDialog.mockResolvedValue(false); + const { result } = renderHook(() => useSignOut()); + + await act(async () => { + await result.current(); + }); + + expect(mockSignOut).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/__test__/handlers.ts b/src/shared/api/__test__/handlers.ts new file mode 100644 index 00000000..179a466e --- /dev/null +++ b/src/shared/api/__test__/handlers.ts @@ -0,0 +1,417 @@ +import { http, HttpResponse } from 'msw'; + +const BASE_URL = 'http://localhost:8080/api'; + +export const handlers = [ + // ────────────────────────────────────────────── + // Playlists + // ────────────────────────────────────────────── + + // POST /v1/playlists — createPlaylist + http.post(`${BASE_URL}/v1/playlists`, async ({ request }) => { + const body = (await request.json()) as { name: string }; + return HttpResponse.json({ + data: { + id: 1, + orderNumber: 1, + name: body.name, + type: 'PLAYLIST', + }, + }); + }), + + // GET /v1/playlists — getPlaylists + http.get(`${BASE_URL}/v1/playlists`, () => { + return HttpResponse.json({ + data: { + playlists: [ + { id: 1, name: 'My Playlist', orderNumber: 1, type: 'PLAYLIST', musicCount: 3 }, + { id: 2, name: 'Grablist', orderNumber: 2, type: 'GRABLIST', musicCount: 0 }, + ], + }, + }); + }), + + // PATCH /v1/playlists/:id — updatePlaylist + http.patch(`${BASE_URL}/v1/playlists/:id`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // DELETE /v1/playlists — removePlaylist + http.delete(`${BASE_URL}/v1/playlists`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // POST /v1/playlists/:id/tracks — addTrackToPlaylist (void) + http.post(`${BASE_URL}/v1/playlists/:id/tracks`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/playlists/:playlistId/tracks/:trackId — removeTrackFromPlaylist + http.delete(`${BASE_URL}/v1/playlists/:playlistId/tracks/:trackId`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // PATCH /v1/playlists/:playlistId/tracks/:trackId/move — moveTrackToPlaylist + http.patch(`${BASE_URL}/v1/playlists/:playlistId/tracks/:trackId/move`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // PUT /v1/playlists/:playlistId/tracks/:trackId — changeTrackOrderInPlaylist + http.put(`${BASE_URL}/v1/playlists/:playlistId/tracks/:trackId`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // GET /v1/playlists/:id/tracks — getTracksOfPlaylist + http.get(`${BASE_URL}/v1/playlists/:id/tracks`, () => { + return HttpResponse.json({ + data: { + content: [ + { + trackId: 10, + linkId: '100', + name: 'Track A', + orderNumber: 1, + duration: '03:30', + thumbnailImage: 'https://example.com/a.jpg', + }, + { + trackId: 20, + linkId: '200', + name: 'Track B', + orderNumber: 2, + duration: '04:15', + thumbnailImage: 'https://example.com/b.jpg', + }, + ], + pagination: { + pageNumber: 0, + pageSize: 100, + totalPages: 1, + totalElements: 2, + hasNext: false, + }, + }, + }); + }), + + // GET /v1/music-search — searchMusics + http.get(`${BASE_URL}/v1/music-search`, ({ request }) => { + const url = new URL(request.url); + const q = url.searchParams.get('q'); + return HttpResponse.json({ + data: { + musicList: [ + { + videoId: 'abc123', + videoTitle: `${q} - Result 1`, + thumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + runningTime: 'PT3M30S', + }, + { + videoId: 'def456', + videoTitle: `${q} - Result 2`, + thumbnailUrl: 'https://img.youtube.com/vi/def456/0.jpg', + runningTime: 'PT4M15S', + }, + ], + }, + }); + }), + + // ────────────────────────────────────────────── + // Users + // ────────────────────────────────────────────── + + // GET /v1/users/me/info — getMyInfo + http.get(`${BASE_URL}/v1/users/me/info`, () => { + return HttpResponse.json({ + data: { + uid: 'user-123', + email: 'test@pfplay.io', + authorityTier: 'FM', + registrationDate: '2024-06-23', + profileUpdated: true, + }, + }); + }), + + // GET /v1/users/me/profile/summary — getMyProfileSummary + http.get(`${BASE_URL}/v1/users/me/profile/summary`, () => { + return HttpResponse.json({ + data: { + nickname: 'TestUser', + introduction: 'Hello', + avatarBodyUri: 'https://example.com/body.png', + avatarFaceUri: 'https://example.com/face.png', + avatarIconUri: 'https://example.com/icon.png', + walletAddress: '0x1234', + activitySummaries: [{ activityType: 'DJ_PNT', score: 100 }], + offsetX: 0, + offsetY: 0, + scale: 1, + }, + }); + }), + + // ────────────────────────────────────────────── + // Partyrooms + // ────────────────────────────────────────────── + + // POST /v1/partyrooms — create + http.post(`${BASE_URL}/v1/partyrooms`, () => { + return HttpResponse.json({ + data: { partyroomId: 42 }, + }); + }), + + // GET /v1/partyrooms — getList + http.get(`${BASE_URL}/v1/partyrooms`, () => { + return HttpResponse.json({ + data: [ + { + partyroomId: 1, + stageType: 'MAIN', + title: 'Main Stage', + introduction: 'Welcome', + crewCount: 5, + playbackActivated: true, + playback: { name: 'Song A', thumbnailImage: 'https://example.com/song.jpg' }, + primaryIcons: [{ avatarIconUri: 'https://example.com/icon.png' }], + }, + ], + }); + }), + + // POST /v1/partyrooms/:id/crews — enter + http.post(`${BASE_URL}/v1/partyrooms/:id/crews`, () => { + return HttpResponse.json({ data: { crewId: 99, gradeType: 'CLUBBER' } }, { status: 201 }); + }), + + // DELETE /v1/partyrooms/:id/crews/me — exit + http.delete(`${BASE_URL}/v1/partyrooms/:id/crews/me`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // PATCH /v1/partyrooms/:id/crews/:crewId/grade — adjustGrade + http.patch(`${BASE_URL}/v1/partyrooms/:id/crews/:crewId/grade`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // GET /v1/partyrooms/:id/summary — getPartyroomDetailSummary + http.get(`${BASE_URL}/v1/partyrooms/:id/summary`, () => { + return HttpResponse.json({ + data: { + title: 'Main Stage', + introduction: 'Welcome to the party', + linkDomain: 'main-stage', + playbackTimeLimit: 300, + currentDj: { crewId: 1, nickname: 'DJ Test', avatarIconUri: 'https://example.com/dj.png' }, + }, + }); + }), + + // ────────────────────────────────────────────── + // Crews + // ────────────────────────────────────────────── + + // GET /v1/crews/me/blocks — getBlockedCrews + http.get(`${BASE_URL}/v1/crews/me/blocks`, () => { + return HttpResponse.json({ + data: [ + { + blockId: 1, + blockedCrewId: 55, + nickname: 'BlockedUser', + avatarIconUri: 'https://example.com/blocked.png', + }, + ], + }); + }), + + // POST /v1/crews/me/blocks — blockCrew + http.post(`${BASE_URL}/v1/crews/me/blocks`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/crews/me/blocks/:blockId — unblockCrew + http.delete(`${BASE_URL}/v1/crews/me/blocks/:blockId`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // ────────────────────────────────────────────── + // DJs + // ────────────────────────────────────────────── + + // POST /v1/partyrooms/:id/dj-queue — registerMeToQueue + http.post(`${BASE_URL}/v1/partyrooms/:id/dj-queue`, () => { + return new HttpResponse(null, { status: 201 }); + }), + + // DELETE /v1/partyrooms/:id/dj-queue/me — unregisterMeFromQueue + http.delete(`${BASE_URL}/v1/partyrooms/:id/dj-queue/me`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // GET /v1/partyrooms/:id/dj-queue — getDjingQueue + http.get(`${BASE_URL}/v1/partyrooms/:id/dj-queue`, () => { + return HttpResponse.json({ + data: { + playbackActivated: true, + queueStatus: 'OPEN', + registered: false, + djs: [ + { + crewId: 1, + orderNumber: 1, + nickname: 'DJ One', + avatarIconUri: 'https://example.com/dj1.png', + }, + { + crewId: 2, + orderNumber: 2, + nickname: 'DJ Two', + avatarIconUri: 'https://example.com/dj2.png', + }, + ], + }, + }); + }), + + // GET /v1/partyrooms/:id/playbacks/histories — getPlaybackHistories + http.get(`${BASE_URL}/v1/partyrooms/:id/playbacks/histories`, () => { + return HttpResponse.json({ + data: [ + { musicName: 'Song A', nickname: 'DJ One', avatarIconUri: 'https://example.com/dj1.png' }, + { musicName: 'Song B', nickname: 'DJ Two', avatarIconUri: 'https://example.com/dj2.png' }, + ], + }); + }), + + // PUT /v1/partyrooms/:id — edit + http.put(`${BASE_URL}/v1/partyrooms/:id`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/partyrooms/:id — close + http.delete(`${BASE_URL}/v1/partyrooms/:id`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // PUT /v1/partyrooms/:id/dj-queue — changeDjQueueStatus + http.put(`${BASE_URL}/v1/partyrooms/:id/dj-queue`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/partyrooms/:id/dj-queue/:djId — deleteDjFromQueue + http.delete(`${BASE_URL}/v1/partyrooms/:id/dj-queue/:djId`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/partyrooms/:id/playbacks/current — skipPlayback + http.delete(`${BASE_URL}/v1/partyrooms/:id/playbacks/current`, () => { + return new HttpResponse(null, { status: 204 }); + }), + + // POST /v1/partyrooms/:id/playbacks/reaction — reaction + http.post(`${BASE_URL}/v1/partyrooms/:id/playbacks/reaction`, () => { + return HttpResponse.json({ + data: { isLiked: true, isDisliked: false, isGrabbed: false }, + }); + }), + + // ────────────────────────────────────────────── + // Penalties + // ────────────────────────────────────────────── + + // POST /v1/partyrooms/:id/penalties — imposePenalty + http.post(`${BASE_URL}/v1/partyrooms/:id/penalties`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // DELETE /v1/partyrooms/:id/penalties/:penaltyId — liftPenalty + http.delete(`${BASE_URL}/v1/partyrooms/:id/penalties/:penaltyId`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // GET /v1/partyrooms/:id/penalties — getPenaltyList + http.get(`${BASE_URL}/v1/partyrooms/:id/penalties`, () => { + return HttpResponse.json({ + data: [ + { + penaltyId: 1, + penaltyType: 'MUTE', + crewId: 10, + nickname: 'User1', + avatarIconUri: 'https://example.com/u1.png', + }, + ], + }); + }), + + // ────────────────────────────────────────────── + // Users (Profile) + // ────────────────────────────────────────────── + + // PUT /v1/users/me/profile/bio — updateMyBio + http.put(`${BASE_URL}/v1/users/me/profile/bio`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // PUT /v1/users/me/profile/avatar — updateMyAvatar + http.put(`${BASE_URL}/v1/users/me/profile/avatar`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // GET /v1/users/me/profile/avatar/bodies — getMyAvatarBodies + http.get(`${BASE_URL}/v1/users/me/profile/avatar/bodies`, () => { + return HttpResponse.json({ + data: [ + { + id: 1, + name: 'Body A', + resourceUri: 'https://example.com/bodyA.png', + available: true, + combinable: true, + defaultSetting: false, + }, + { + id: 2, + name: 'Body B', + resourceUri: 'https://example.com/bodyB.png', + available: true, + combinable: false, + defaultSetting: true, + }, + ], + }); + }), + + // GET /v1/users/me/profile/avatar/faces — getMyAvatarFaces + http.get(`${BASE_URL}/v1/users/me/profile/avatar/faces`, () => { + return HttpResponse.json({ + data: [ + { id: 1, name: 'Face A', resourceUri: 'https://example.com/faceA.png', available: true }, + ], + }); + }), + + // PUT /v1/users/me/profile/wallet — updateMyWallet + http.put(`${BASE_URL}/v1/users/me/profile/wallet`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // ────────────────────────────────────────────── + // Auth + // ────────────────────────────────────────────── + + // POST /v1/users/guests/sign — signInGuest + http.post(`${BASE_URL}/v1/users/guests/sign`, () => { + return new HttpResponse(null, { status: 200 }); + }), + + // POST /v1/auth/logout — signOut + http.post(`${BASE_URL}/v1/auth/logout`, () => { + return new HttpResponse(null, { status: 200 }); + }), +]; diff --git a/src/shared/api/__test__/msw-server.ts b/src/shared/api/__test__/msw-server.ts new file mode 100644 index 00000000..cab3d316 --- /dev/null +++ b/src/shared/api/__test__/msw-server.ts @@ -0,0 +1,8 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/src/shared/api/__test__/test-utils.tsx b/src/shared/api/__test__/test-utils.tsx new file mode 100644 index 00000000..ccc43742 --- /dev/null +++ b/src/shared/api/__test__/test-utils.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, RenderHookOptions } from '@testing-library/react'; + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +export function TestWrapper({ children }: { children: ReactNode }) { + const queryClient = createTestQueryClient(); + return {children}; +} + +export function renderWithClient( + hook: (props: TProps) => TResult, + options?: Omit, 'wrapper'> +) { + const queryClient = createTestQueryClient(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + ...renderHook(hook, { ...options, wrapper }), + queryClient, + }; +} diff --git a/src/shared/api/http/__fixture__/playlist-tracks.fixture.ts b/src/shared/api/http/__fixture__/playlist-tracks.fixture.ts index 29776d1c..98b16306 100644 --- a/src/shared/api/http/__fixture__/playlist-tracks.fixture.ts +++ b/src/shared/api/http/__fixture__/playlist-tracks.fixture.ts @@ -3,7 +3,7 @@ import { PlaylistTrack } from '../types/playlists'; export const fixturePlaylistTracks: PlaylistTrack[] = [ { trackId: 1, - linkId: 1, + linkId: '1', orderNumber: 1, name: 'BLACKPINK(블랙핑크) - Shut Down @인기가요sssss inkigayo 20220925 long long long long long long long longlong long long long text', duration: '00:00', @@ -11,7 +11,7 @@ export const fixturePlaylistTracks: PlaylistTrack[] = [ }, { trackId: 2, - linkId: 2, + linkId: '2', orderNumber: 2, name: 'BLACKPINK(블랙핑크)checkehck ', duration: '04:20', diff --git a/src/shared/api/http/client/interceptors/request.test.ts b/src/shared/api/http/client/interceptors/request.test.ts new file mode 100644 index 00000000..6e7d46fa --- /dev/null +++ b/src/shared/api/http/client/interceptors/request.test.ts @@ -0,0 +1,71 @@ +vi.mock('@/shared/lib/functions/log/network-log', () => ({ + printRequestLog: vi.fn(), +})); +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + default: () => (logFn: Function) => logFn, +})); + +import { InternalAxiosRequestConfig } from 'axios'; +import { printRequestLog } from '@/shared/lib/functions/log/network-log'; +import { logRequest } from './request'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAxiosRequestConfig( + overrides: Partial = {} +): InternalAxiosRequestConfig { + return { + method: 'get', + url: '/api/test', + headers: {} as any, + ...overrides, + }; +} + +describe('logRequest', () => { + test('config를 그대로 반환한다', () => { + const config = createAxiosRequestConfig(); + + const result = logRequest(config); + + expect(result).toBe(config); + }); + + test('로거에 method, url, params, data를 전달한다', () => { + const config = createAxiosRequestConfig({ + method: 'post', + url: '/api/users', + params: { page: 1 }, + data: { name: 'test' }, + }); + + logRequest(config); + + expect(printRequestLog).toHaveBeenCalledWith({ + method: 'post', + endPoint: '/api/users', + requestParams: { page: 1 }, + requestData: { name: 'test' }, + }); + }); + + test('params와 data가 없으면 undefined로 전달한다', () => { + const config = createAxiosRequestConfig({ + method: 'get', + url: '/api/health', + }); + + logRequest(config); + + expect(printRequestLog).toHaveBeenCalledWith({ + method: 'get', + endPoint: '/api/health', + requestParams: undefined, + requestData: undefined, + }); + }); +}); diff --git a/src/shared/api/http/client/interceptors/response.integration.test.ts b/src/shared/api/http/client/interceptors/response.integration.test.ts new file mode 100644 index 00000000..6d950b67 --- /dev/null +++ b/src/shared/api/http/client/interceptors/response.integration.test.ts @@ -0,0 +1,122 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { + usersService, + partyroomsService, + crewsService, + djsService, +} from '@/shared/api/http/services'; + +describe('Interceptor chain integration (401 auth errors across services)', () => { + function create401Handler(url: string, errorCode: string) { + return http.get(url, () => { + return HttpResponse.json( + { + data: { + status: 'UNAUTHORIZED', + code: 401, + message: 'ACCESS_TOKEN 이 만료됨', + errorCode, + }, + }, + { status: 401 } + ); + }); + } + + it('usersService.getMyInfo 401 → AxiosError with errorCode emitted', async () => { + server.use(create401Handler('http://localhost:8080/api/v1/users/me/info', 'JWT-003')); + + const emitted: string[] = []; + const unsub = errorEmitter.on('JWT-003' as any, () => emitted.push('JWT-003')); + + try { + await usersService.getMyInfo(); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(401); + } + + expect(emitted).toContain('JWT-003'); + unsub(); + }); + + it('partyroomsService.getList 401 → AxiosError propagated', async () => { + server.use(create401Handler('http://localhost:8080/api/v1/partyrooms', 'JWT-001')); + + try { + await partyroomsService.getList(); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(401); + } + }); + + it('crewsService.getBlockedCrews 401 → AxiosError propagated', async () => { + server.use(create401Handler('http://localhost:8080/api/v1/crews/me/blocks', 'JWT-002')); + + try { + await crewsService.getBlockedCrews(); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(401); + } + }); + + it('djsService.getPlaybackHistories 401 → AxiosError propagated', async () => { + server.use( + http.get('http://localhost:8080/api/v1/partyrooms/:id/playbacks/histories', () => { + return HttpResponse.json( + { + data: { + status: 'UNAUTHORIZED', + code: 401, + message: 'ACCESS_TOKEN 이 유효하지 않음', + errorCode: 'JWT-002', + }, + }, + { status: 401 } + ); + }) + ); + + try { + await djsService.getPlaybackHistories({ partyroomId: 1 }); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(401); + } + }); + + it('unwrapError extracts nested data from error response', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/crews', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '파티룸 정원 초과', + errorCode: 'PTR-003', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await partyroomsService.enter({ partyroomId: 1 }); + fail('Expected error'); + } catch (e: any) { + // unwrapError should have unwrapped { data: { ... } } → { ... } + expect(e.response.data.errorCode).toBe('PTR-003'); + expect(e.response.data.message).toBe('파티룸 정원 초과'); + } + }); +}); diff --git a/src/shared/api/http/client/interceptors/response.test.ts b/src/shared/api/http/client/interceptors/response.test.ts new file mode 100644 index 00000000..7e48ec17 --- /dev/null +++ b/src/shared/api/http/client/interceptors/response.test.ts @@ -0,0 +1,207 @@ +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + default: () => (fn: any) => fn, +})); + +vi.mock('@/shared/lib/functions/log/network-log', () => ({ + printResponseLog: vi.fn(), + printErrorLog: vi.fn(), +})); + +vi.mock('@/shared/api/http/error/get-error-message', () => ({ + getErrorMessage: vi.fn(() => 'mocked error message'), +})); + +vi.mock('@/shared/api/http/error/get-error-code', () => ({ + getErrorCode: vi.fn(), +})); + +vi.mock('@/shared/api/http/error/error-emitter', () => ({ + __esModule: true, + default: { emit: vi.fn() }, +})); + +vi.mock('@/shared/lib/functions/is-pure-object', () => ({ + isPureObject: vi.fn( + (obj: unknown) => obj !== null && typeof obj === 'object' && !Array.isArray(obj) + ), +})); + +import { AxiosError, AxiosResponse } from 'axios'; +import errorEmitter from '@/shared/api/http/error/error-emitter'; +import { getErrorCode } from '@/shared/api/http/error/get-error-code'; +import { getErrorMessage } from '@/shared/api/http/error/get-error-message'; +import { printErrorLog, printResponseLog } from '@/shared/lib/functions/log/network-log'; +import { logResponse, unwrapResponse, logError, unwrapError, emitError } from './response'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAxiosResponse(overrides: { + data?: any; + method?: string; + url?: string; +}): AxiosResponse { + return { + data: overrides.data, + status: 200, + statusText: 'OK', + headers: {}, + config: { + method: overrides.method ?? 'get', + url: overrides.url ?? '/api/test', + headers: {} as any, + }, + }; +} + +function createAxiosError(overrides: { + method?: string; + url?: string; + responseData?: any; + hasResponse?: boolean; +}): AxiosError { + return { + message: 'Request failed', + name: 'AxiosError', + isAxiosError: true, + toJSON: () => ({}), + config: { + method: overrides.method ?? 'post', + url: overrides.url ?? '/api/test', + headers: {} as any, + }, + response: + overrides.hasResponse === false + ? undefined + : { + data: overrides.responseData ?? {}, + status: 500, + statusText: 'Internal Server Error', + headers: {}, + config: { headers: {} as any }, + }, + } as AxiosError; +} + +describe('response interceptors', () => { + describe('logResponse', () => { + test('응답 로그 출력 후 response 그대로 반환', () => { + const response = createAxiosResponse({ + data: { data: { id: 1 } }, + method: 'get', + url: '/api/users', + }); + + const result = logResponse(response); + + expect(printResponseLog).toHaveBeenCalledWith({ + method: 'get', + endPoint: '/api/users', + response: { id: 1 }, + }); + expect(result).toBe(response); + }); + + test('data.data 없으면 data 자체를 로그에 사용', () => { + const response = createAxiosResponse({ + data: { message: 'ok' }, + method: 'post', + url: '/api/health', + }); + + logResponse(response); + + expect(printResponseLog).toHaveBeenCalledWith({ + method: 'post', + endPoint: '/api/health', + response: { message: 'ok' }, + }); + }); + }); + + describe('unwrapResponse', () => { + test('data.data 있으면 data.data 반환', () => { + const response = createAxiosResponse({ data: { data: { id: 1, name: 'test' } } }); + + expect(unwrapResponse(response)).toEqual({ id: 1, name: 'test' }); + }); + + test('data.data 없으면 data 반환', () => { + const response = createAxiosResponse({ data: { message: 'ok' } }); + + expect(unwrapResponse(response)).toEqual({ message: 'ok' }); + }); + }); + + describe('logError', () => { + test('getErrorMessage 호출, printErrorLog 호출, Promise.reject 반환', async () => { + const error = createAxiosError({ method: 'post', url: '/api/users' }); + + await expect(logError(error)).rejects.toBe(error); + + expect(getErrorMessage).toHaveBeenCalledWith(error); + expect(printErrorLog).toHaveBeenCalledWith({ + method: 'post', + endPoint: '/api/users', + errorMessage: 'mocked error message', + error, + }); + }); + }); + + describe('unwrapError', () => { + test('response.data에 data 프로퍼티 있으면 e.response.data를 data.data로 교체', async () => { + const error = createAxiosError({ + responseData: { data: { errorCode: 'JWT-001' } }, + }); + + await expect(unwrapError(error)).rejects.toBe(error); + const response = error.response as { data: unknown }; + expect(response.data).toEqual({ errorCode: 'JWT-001' }); + }); + + test('response.data에 data 프로퍼티 없으면 그대로 유지', async () => { + const originalData = { message: 'error' }; + const error = createAxiosError({ responseData: originalData }); + + await expect(unwrapError(error)).rejects.toBe(error); + const response = error.response as { data: unknown }; + expect(response.data).toEqual(originalData); + }); + + test('response 없으면 그대로 reject', async () => { + const error = createAxiosError({ hasResponse: false }); + + await expect(unwrapError(error)).rejects.toBe(error); + }); + }); + + describe('emitError', () => { + test('유효한 ErrorCode 있으면 errorEmitter.emit 호출', async () => { + (getErrorCode as Mock).mockReturnValue('JWT-001'); + + const error = createAxiosError({}); + + await expect(emitError(error)).rejects.toBe(error); + expect(errorEmitter.emit).toHaveBeenCalledWith('JWT-001'); + }); + + test('ErrorCode 없으면 emit 호출 안 함', async () => { + (getErrorCode as Mock).mockReturnValue(undefined); + + const error = createAxiosError({}); + + await expect(emitError(error)).rejects.toBe(error); + expect(errorEmitter.emit).not.toHaveBeenCalled(); + }); + + test('항상 Promise.reject 반환', async () => { + (getErrorCode as Mock).mockReturnValue(undefined); + + const error = createAxiosError({}); + + await expect(emitError(error)).rejects.toBe(error); + }); + }); +}); diff --git a/src/shared/api/http/error/error-emitter.test.ts b/src/shared/api/http/error/error-emitter.test.ts new file mode 100644 index 00000000..48accd98 --- /dev/null +++ b/src/shared/api/http/error/error-emitter.test.ts @@ -0,0 +1,65 @@ +import errorEmitter from './error-emitter'; +import { ErrorCode } from '../types/@shared'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ErrorEmitter', () => { + test('싱글턴 인스턴스 — 동일한 객체 참조', () => { + // 모듈 레벨 export가 항상 같은 인스턴스를 반환하는지 확인 + expect(errorEmitter).toBeDefined(); + expect(typeof errorEmitter.on).toBe('function'); + expect(typeof errorEmitter.emit).toBe('function'); + }); + + test('emit(ErrorCode) → 구독자에게 전달', () => { + const callback = vi.fn(); + const unsubscribe = errorEmitter.on(ErrorCode.ACCESS_TOKEN_EXPIRED, callback); + + errorEmitter.emit(ErrorCode.ACCESS_TOKEN_EXPIRED); + + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + test('on/emit/unsubscribe 라이프사이클 — 구독 해제 후 콜백 호출 안 됨', () => { + const callback = vi.fn(); + const unsubscribe = errorEmitter.on(ErrorCode.UNAUTHORIZED_SESSION, callback); + + errorEmitter.emit(ErrorCode.UNAUTHORIZED_SESSION); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + + errorEmitter.emit(ErrorCode.UNAUTHORIZED_SESSION); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('다른 ErrorCode 이벤트는 구독자에게 전달되지 않음', () => { + const callback = vi.fn(); + const unsubscribe = errorEmitter.on(ErrorCode.ACCESS_TOKEN_NOT_FOUND, callback); + + errorEmitter.emit(ErrorCode.ALREADY_BLOCKED_CREW); + + expect(callback).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + test('같은 ErrorCode에 여러 구독자 등록 가능', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + const unsub1 = errorEmitter.on(ErrorCode.NOT_FOUND_ROOM, cb1); + const unsub2 = errorEmitter.on(ErrorCode.NOT_FOUND_ROOM, cb2); + + errorEmitter.emit(ErrorCode.NOT_FOUND_ROOM); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + }); +}); diff --git a/src/shared/api/http/error/get-error-code.test.ts b/src/shared/api/http/error/get-error-code.test.ts new file mode 100644 index 00000000..f736b6c4 --- /dev/null +++ b/src/shared/api/http/error/get-error-code.test.ts @@ -0,0 +1,64 @@ +vi.mock('axios', () => ({ + isAxiosError: vi.fn(), +})); + +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + default: () => (fn: any) => fn, +})); + +vi.mock('@/shared/lib/functions/log/logger', () => ({ + warnLog: vi.fn(), +})); + +import { isAxiosError } from 'axios'; +import { warnLog } from '@/shared/lib/functions/log/logger'; +import { getErrorCode } from './get-error-code'; +import { ErrorCode } from '../types/@shared'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAxiosErrorWithCode(errorCode: string, nested = false) { + const response = nested ? { data: { data: { errorCode } } } : { data: { errorCode } }; + + return { response, isAxiosError: true }; +} + +describe('getErrorCode', () => { + test('AxiosError + 유효한 ErrorCode → 해당 코드 반환', () => { + const error = createAxiosErrorWithCode(ErrorCode.ACCESS_TOKEN_EXPIRED); + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorCode(error)).toBe(ErrorCode.ACCESS_TOKEN_EXPIRED); + }); + + test('AxiosError + 중첩 data.data.errorCode 구조 → 코드 추출', () => { + const error = createAxiosErrorWithCode(ErrorCode.UNAUTHORIZED_SESSION, true); + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorCode(error)).toBe(ErrorCode.UNAUTHORIZED_SESSION); + }); + + test('AxiosError + 알 수 없는 errorCode → undefined 반환, warnLog 호출', () => { + const error = createAxiosErrorWithCode('UNKNOWN-999'); + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorCode(error)).toBeUndefined(); + expect(warnLog).toHaveBeenCalledWith('Unknown errorCode: UNKNOWN-999'); + }); + + test('AxiosError + errorCode 없음 → undefined', () => { + const error = { response: { data: {} }, isAxiosError: true }; + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorCode(error)).toBeUndefined(); + }); + + test('일반 Error → undefined', () => { + (isAxiosError as Mock).mockReturnValue(false); + + const error = new Error('일반 에러'); + expect(getErrorCode(error)).toBeUndefined(); + }); +}); diff --git a/src/shared/api/http/error/get-error-message.test.ts b/src/shared/api/http/error/get-error-message.test.ts new file mode 100644 index 00000000..25791cf9 --- /dev/null +++ b/src/shared/api/http/error/get-error-message.test.ts @@ -0,0 +1,61 @@ +vi.mock('axios', () => ({ + isAxiosError: vi.fn(), +})); + +import { isAxiosError } from 'axios'; +import { getErrorMessage } from './get-error-message'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAxiosError(overrides: { message?: string; responseData?: Record }) { + return { + message: overrides.message ?? 'Request failed', + response: overrides.responseData ? { data: overrides.responseData } : undefined, + isAxiosError: true, + }; +} + +describe('getErrorMessage', () => { + test('string 입력 → 그대로 반환', () => { + (isAxiosError as Mock).mockReturnValue(false); + + expect(getErrorMessage('서버 오류')).toBe('서버 오류'); + }); + + test('AxiosError + response.data.message 있음 → data.message 반환', () => { + const error = createAxiosError({ + message: 'Request failed', + responseData: { message: '인증이 만료되었습니다' }, + }); + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorMessage(error)).toBe('인증이 만료되었습니다'); + }); + + test('AxiosError + response.data.message 없음 → err.message 반환', () => { + const error = createAxiosError({ + message: 'Network Error', + responseData: {}, + }); + (isAxiosError as Mock).mockReturnValue(true); + + expect(getErrorMessage(error)).toBe('Network Error'); + }); + + test('Error 인스턴스 → err.message 반환', () => { + (isAxiosError as Mock).mockReturnValue(false); + + const error = new Error('일반 에러'); + expect(getErrorMessage(error)).toBe('일반 에러'); + }); + + test('unknown 타입 → "Unknown error occurred" 반환', () => { + (isAxiosError as Mock).mockReturnValue(false); + + expect(getErrorMessage(12345)).toBe('Unknown error occurred'); + expect(getErrorMessage(null)).toBe('Unknown error occurred'); + expect(getErrorMessage(undefined)).toBe('Unknown error occurred'); + }); +}); diff --git a/src/shared/api/http/error/is-auth-error.test.ts b/src/shared/api/http/error/is-auth-error.test.ts new file mode 100644 index 00000000..44423fd7 --- /dev/null +++ b/src/shared/api/http/error/is-auth-error.test.ts @@ -0,0 +1,48 @@ +vi.mock('axios', () => ({ + isAxiosError: vi.fn(), +})); + +import { isAxiosError } from 'axios'; +import isAuthError from './is-auth-error'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAxiosError(overrides: { status?: number; responseStatus?: number }) { + return { + status: overrides.status, + response: overrides.responseStatus != null ? { status: overrides.responseStatus } : undefined, + isAxiosError: true, + }; +} + +describe('isAuthError', () => { + test('AxiosError + status 401 → true', () => { + const error = createAxiosError({ status: 401 }); + (isAxiosError as Mock).mockReturnValue(true); + + expect(isAuthError(error)).toBe(true); + }); + + test('AxiosError + response.status 401 → true', () => { + const error = createAxiosError({ responseStatus: 401 }); + (isAxiosError as Mock).mockReturnValue(true); + + expect(isAuthError(error)).toBe(true); + }); + + test('AxiosError + status 403 → false', () => { + const error = createAxiosError({ status: 403, responseStatus: 403 }); + (isAxiosError as Mock).mockReturnValue(true); + + expect(isAuthError(error)).toBe(false); + }); + + test('일반 Error → false', () => { + (isAxiosError as Mock).mockReturnValue(false); + + const error = new Error('일반 에러'); + expect(isAuthError(error)).toBe(false); + }); +}); diff --git a/src/shared/api/http/error/use-on-error.hook.test.ts b/src/shared/api/http/error/use-on-error.hook.test.ts new file mode 100644 index 00000000..0ca609e8 --- /dev/null +++ b/src/shared/api/http/error/use-on-error.hook.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from '@testing-library/react'; +import errorEmitter from './error-emitter'; +import useOnError from './use-on-error.hook'; + +describe('useOnError', () => { + test('마운트 시 errorEmitter에 리스너를 등록한다', () => { + const onSpy = vi.spyOn(errorEmitter, 'on'); + const callback = vi.fn(); + + renderHook(() => useOnError('JWT-003' as any, callback)); + + expect(onSpy).toHaveBeenCalledWith('JWT-003', callback); + onSpy.mockRestore(); + }); + + test('에러 코드 발행 시 콜백이 호출된다', () => { + const callback = vi.fn(); + renderHook(() => useOnError('JWT-001' as any, callback)); + + errorEmitter.emit('JWT-001' as any); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('언마운트 시 리스너가 해제된다', () => { + const callback = vi.fn(); + const { unmount } = renderHook(() => useOnError('JWT-002' as any, callback)); + + unmount(); + errorEmitter.emit('JWT-002' as any); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/http/services/__test__/crews.integration.test.ts b/src/shared/api/http/services/__test__/crews.integration.test.ts new file mode 100644 index 00000000..7a41b401 --- /dev/null +++ b/src/shared/api/http/services/__test__/crews.integration.test.ts @@ -0,0 +1,81 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { crewsService } from '@/shared/api/http/services'; + +describe('CrewsService integration (axios → interceptors → MSW)', () => { + describe('getBlockedCrews', () => { + it('returns unwrapped blocked crew list', async () => { + const result = await crewsService.getBlockedCrews(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + blockId: 1, + blockedCrewId: 55, + nickname: 'BlockedUser', + }); + }); + }); + + describe('blockCrew', () => { + it('resolves without throwing on success', async () => { + await crewsService.blockCrew({ crewId: 42 }); + }); + + it('rejects with BLK-002 on already blocked', async () => { + server.use( + http.post('http://localhost:8080/api/v1/crews/me/blocks', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '이미 차단된 크루', + errorCode: 'BLK-002', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await crewsService.blockCrew({ crewId: 42 }); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.data.errorCode).toBe('BLK-002'); + } + }); + }); + + describe('unblockCrew', () => { + it('resolves without throwing on success', async () => { + await crewsService.unblockCrew({ blockId: 1 }); + }); + + it('rejects with BLK-001 when block not found', async () => { + server.use( + http.delete('http://localhost:8080/api/v1/crews/me/blocks/:blockId', () => { + return HttpResponse.json( + { + data: { + status: 'NOT_FOUND', + code: 404, + message: '차단 기록을 찾을 수 없음', + errorCode: 'BLK-001', + }, + }, + { status: 404 } + ); + }) + ); + + try { + await crewsService.unblockCrew({ blockId: 999 }); + fail('Expected error'); + } catch (e: any) { + expect(e.response.data.errorCode).toBe('BLK-001'); + } + }); + }); +}); diff --git a/src/shared/api/http/services/__test__/djs.integration.test.ts b/src/shared/api/http/services/__test__/djs.integration.test.ts new file mode 100644 index 00000000..156d9fe3 --- /dev/null +++ b/src/shared/api/http/services/__test__/djs.integration.test.ts @@ -0,0 +1,112 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { djsService, partyroomsService } from '@/shared/api/http/services'; + +describe('DjsService integration (axios → interceptors → MSW)', () => { + describe('registerMeToQueue', () => { + it('resolves without throwing on success', async () => { + await djsService.registerMeToQueue({ partyroomId: 1, playlistId: 10 }); + }); + + it('rejects with DJ-001 when already registered', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/dj-queue', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '이미 DJ로 등록됨', + errorCode: 'DJ-001', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await djsService.registerMeToQueue({ partyroomId: 1, playlistId: 10 }); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.data.errorCode).toBe('DJ-001'); + } + }); + + it('rejects with DJ-002 when queue is closed', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/dj-queue', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: 'DJ 대기열이 닫혀 있음', + errorCode: 'DJ-002', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await djsService.registerMeToQueue({ partyroomId: 1, playlistId: 10 }); + fail('Expected error'); + } catch (e: any) { + expect(e.response.data.errorCode).toBe('DJ-002'); + } + }); + + it('rejects with DJ-003 when playlist is empty', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/dj-queue', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '비어있는 재생목록은 등록할 수 없음', + errorCode: 'DJ-003', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await djsService.registerMeToQueue({ partyroomId: 1, playlistId: 10 }); + fail('Expected error'); + } catch (e: any) { + expect(e.response.data.errorCode).toBe('DJ-003'); + } + }); + }); + + describe('unregisterMeFromQueue', () => { + it('resolves without throwing on success', async () => { + await djsService.unregisterMeFromQueue({ partyroomId: 1 }); + }); + }); + + describe('getPlaybackHistories', () => { + it('returns unwrapped history list', async () => { + const result = await djsService.getPlaybackHistories({ partyroomId: 1 }); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ musicName: 'Song A', nickname: 'DJ One' }); + }); + }); + + describe('cross-domain: DJ queue state after registration', () => { + it('getDjingQueue returns queue with registered DJs', async () => { + const queue = await partyroomsService.getDjingQueue({ partyroomId: 1 }); + + expect(queue.queueStatus).toBe('OPEN'); + expect(queue.djs).toHaveLength(2); + expect(queue.djs[0].nickname).toBe('DJ One'); + }); + }); +}); diff --git a/src/shared/api/http/services/__test__/partyrooms.integration.test.ts b/src/shared/api/http/services/__test__/partyrooms.integration.test.ts new file mode 100644 index 00000000..73906c70 --- /dev/null +++ b/src/shared/api/http/services/__test__/partyrooms.integration.test.ts @@ -0,0 +1,138 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { partyroomsService } from '@/shared/api/http/services'; + +describe('PartyroomsService integration (axios → interceptors → MSW)', () => { + describe('create', () => { + it('returns unwrapped partyroomId on success', async () => { + const result = await partyroomsService.create({ + title: 'Test Room', + introduction: 'Hello', + playbackTimeLimit: 300, + }); + + expect(result).toEqual({ partyroomId: 42 }); + }); + }); + + describe('getList', () => { + it('returns unwrapped partyroom list', async () => { + const result = await partyroomsService.getList(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + partyroomId: 1, + stageType: 'MAIN', + title: 'Main Stage', + }); + }); + }); + + describe('enter', () => { + it('returns crewId and gradeType on success', async () => { + const result = await partyroomsService.enter({ partyroomId: 1 }); + + expect(result).toEqual({ crewId: 99, gradeType: 'CLUBBER' }); + }); + + it('rejects with PTR-003 on capacity exceeded', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/crews', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '파티룸 정원 초과', + errorCode: 'PTR-003', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await partyroomsService.enter({ partyroomId: 1 }); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(400); + expect(e.response.data.errorCode).toBe('PTR-003'); + } + }); + + it('rejects with PTR-001 when room not found', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/crews', () => { + return HttpResponse.json( + { + data: { + status: 'NOT_FOUND', + code: 404, + message: '파티룸을 찾을 수 없음', + errorCode: 'PTR-001', + }, + }, + { status: 404 } + ); + }) + ); + + try { + await partyroomsService.enter({ partyroomId: 999 }); + fail('Expected error'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.data.errorCode).toBe('PTR-001'); + } + }); + + it('rejects with PTR-002 when room already terminated', async () => { + server.use( + http.post('http://localhost:8080/api/v1/partyrooms/:id/crews', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '이미 종료된 파티룸', + errorCode: 'PTR-002', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await partyroomsService.enter({ partyroomId: 1 }); + fail('Expected error'); + } catch (e: any) { + expect(e.response.data.errorCode).toBe('PTR-002'); + } + }); + }); + + describe('adjustGrade', () => { + it('resolves without throwing on success', async () => { + await partyroomsService.adjustGrade({ + partyroomId: 1, + crewId: 10, + gradeType: 'MODERATOR' as any, + }); + }); + }); + + describe('getPartyroomDetailSummary', () => { + it('returns unwrapped detail summary', async () => { + const result = await partyroomsService.getPartyroomDetailSummary({ partyroomId: 1 }); + + expect(result).toMatchObject({ + title: 'Main Stage', + linkDomain: 'main-stage', + playbackTimeLimit: 300, + }); + }); + }); +}); diff --git a/src/shared/api/http/services/__test__/playlists.integration.test.ts b/src/shared/api/http/services/__test__/playlists.integration.test.ts new file mode 100644 index 00000000..74e615f3 --- /dev/null +++ b/src/shared/api/http/services/__test__/playlists.integration.test.ts @@ -0,0 +1,95 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '@/shared/api/__test__/msw-server'; +import { playlistsService } from '@/shared/api/http/services'; + +describe('PlaylistsService integration (axios → interceptors → MSW)', () => { + describe('createPlaylist', () => { + it('returns unwrapped response on success', async () => { + const result = await playlistsService.createPlaylist({ name: 'New Playlist' }); + + expect(result).toEqual({ + id: 1, + orderNumber: 1, + name: 'New Playlist', + type: 'PLAYLIST', + }); + }); + }); + + describe('getPlaylists', () => { + it('returns unwrapped playlist list', async () => { + const result = await playlistsService.getPlaylists(); + + expect(result.playlists).toHaveLength(2); + expect(result.playlists[0]).toMatchObject({ id: 1, name: 'My Playlist' }); + }); + }); + + describe('searchMusics', () => { + it('returns unwrapped search results', async () => { + const result = await playlistsService.searchMusics({ q: 'test', platform: 'youtube' }); + + expect(result.musicList).toHaveLength(2); + expect(result.musicList[0].videoTitle).toBe('test - Result 1'); + }); + }); + + describe('addTrackToPlaylist', () => { + it('resolves without throwing on success', async () => { + await playlistsService.addTrackToPlaylist(1, { + linkId: 'abc123', + name: 'Test Track', + duration: 'PT3M30S', + thumbnailImage: 'https://example.com/thumb.jpg', + }); + // void endpoint — just verifying the full pipeline doesn't throw + }); + }); + + describe('API error (400)', () => { + it('rejects with AxiosError containing errorCode', async () => { + server.use( + http.post('http://localhost:8080/api/v1/playlists', () => { + return HttpResponse.json( + { + data: { + status: 'BAD_REQUEST', + code: 400, + message: '재생목록 개수 제한을 초과함', + errorCode: 'PLL-002', + }, + }, + { status: 400 } + ); + }) + ); + + try { + await playlistsService.createPlaylist({ name: 'Fail' }); + fail('Expected error to be thrown'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response.status).toBe(400); + expect(e.response.data.errorCode).toBe('PLL-002'); + } + }); + }); + + describe('Network error', () => { + it('rejects with AxiosError on network failure', async () => { + server.use( + http.get('http://localhost:8080/api/v1/playlists', () => { + return HttpResponse.error(); + }) + ); + + try { + await playlistsService.getPlaylists(); + fail('Expected error to be thrown'); + } catch (e: any) { + expect(e.isAxiosError).toBe(true); + expect(e.response).toBeUndefined(); + } + }); + }); +}); diff --git a/src/shared/api/http/services/djs.ts b/src/shared/api/http/services/djs.ts index 72b998d8..cfdae7d8 100644 --- a/src/shared/api/http/services/djs.ts +++ b/src/shared/api/http/services/djs.ts @@ -15,15 +15,15 @@ export default class DjsService extends HTTPClient implements DjsClient { private ROUTE_V1 = 'v1/partyrooms'; public registerMeToQueue({ partyroomId, ...body }: RegisterMeToQueuePayload) { - return this.post(`${this.ROUTE_V1}/${partyroomId}/djs`, body); + return this.post(`${this.ROUTE_V1}/${partyroomId}/dj-queue`, body); } public unregisterMeFromQueue({ partyroomId }: UnregisterMeFromQueuePayload) { - return this.delete(`${this.ROUTE_V1}/${partyroomId}/djs/me`); + return this.delete(`${this.ROUTE_V1}/${partyroomId}/dj-queue/me`); } public unregisterDjFromQueue({ partyroomId, djId }: UnregisterDjFromQueuePayload) { - return this.delete(`${this.ROUTE_V1}/${partyroomId}/djs/${djId}`); + return this.delete(`${this.ROUTE_V1}/${partyroomId}/dj-queue/${djId}`); } public getPlaybackHistories({ partyroomId }: GetPlaybackHistoryPayload) { @@ -31,6 +31,6 @@ export default class DjsService extends HTTPClient implements DjsClient { } public skipPlayback({ partyroomId }: SkipPlaybackPayload) { - return this.post(`${this.ROUTE_V1}/${partyroomId}/playback/skip`); + return this.delete(`${this.ROUTE_V1}/${partyroomId}/playbacks/current`); } } diff --git a/src/shared/api/http/services/partyrooms.ts b/src/shared/api/http/services/partyrooms.ts index f10d19e6..091ea6bf 100644 --- a/src/shared/api/http/services/partyrooms.ts +++ b/src/shared/api/http/services/partyrooms.ts @@ -5,26 +5,22 @@ import HTTPClient from '../client/client'; import { getErrorCode } from '../error/get-error-code'; import type { DjingQueue, - EnterByLinkPayload, - EnterByLinkResponse, + GetPartyroomByLinkPayload, + GetPartyroomByLinkResponse, EnterPayload, EnterResponse, ExitPayload, GetDjingQueuePayload, - GetCrewsPayload, GetNoticePayload, GetNoticeResponse, GetSetupInfoPayload, GetSetUpInfoResponse, - PartyroomCrewSummary, PartyroomsClient, PartyroomDetailSummary, ReactionPayload, AdjustGradePayload, GetPartyroomDetailSummaryPayload, ReactionResponse, - GetRoomIdByDomainPayload, - GetRoomIdByDomainResponse, PartyroomSummary, CreatePartyroomPayload, CreatePartyroomResponse, @@ -66,10 +62,6 @@ export default class PartyroomsService extends HTTPClient implements PartyroomsC return this.get(`${this.ROUTE_V1}/${partyroomId}/setup`); } - public getCrews({ partyroomId }: GetCrewsPayload) { - return this.get(`${this.ROUTE_V1}/${partyroomId}/crews`); - } - public getDjingQueue({ partyroomId }: GetDjingQueuePayload) { return this.get(`${this.ROUTE_V1}/${partyroomId}/dj-queue`); } @@ -93,11 +85,11 @@ export default class PartyroomsService extends HTTPClient implements PartyroomsC ), }) public enter({ partyroomId }: EnterPayload) { - return this.post(`${this.ROUTE_V1}/${partyroomId}/enter`); + return this.post(`${this.ROUTE_V1}/${partyroomId}/crews`); } public exit({ partyroomId }: ExitPayload) { - return this.post(`${this.ROUTE_V1}/${partyroomId}/exit`); + return this.delete(`${this.ROUTE_V1}/${partyroomId}/crews/me`); } public reaction({ partyroomId, ...body }: ReactionPayload) { @@ -108,12 +100,8 @@ export default class PartyroomsService extends HTTPClient implements PartyroomsC return this.patch(`${this.ROUTE_V1}/${partyroomId}/crews/${crewId}/grade`, body); } - public getRoomIdByDomain({ domain }: GetRoomIdByDomainPayload) { - return this.get(`${this.ROUTE_V1}/link/${domain}/enter`); - } - - public enterByLink({ linkDomain }: EnterByLinkPayload) { - return this.get(`${this.ROUTE_V1}/link/${linkDomain}/enter`); + public getPartyroomByLink({ linkDomain }: GetPartyroomByLinkPayload) { + return this.get(`${this.ROUTE_V1}/link/${linkDomain}`); } public getPenaltyList({ partyroomId }: GetPenaltyListPayload) { diff --git a/src/shared/api/http/services/playlists.ts b/src/shared/api/http/services/playlists.ts index 79edb107..4a3f15a7 100644 --- a/src/shared/api/http/services/playlists.ts +++ b/src/shared/api/http/services/playlists.ts @@ -13,12 +13,9 @@ import type { CreatePlaylistResponse, Playlist, UpdatePlaylistRequestParams, - UpdatePlaylistResponse, AddTrackToPlaylistRequestBody, RemovePlaylistRequestBody, - RemovePlaylistResponse, RemoveTrackFromPlaylistRequestParams, - RemoveTrackFromPlaylistResponse, PlaylistsClient, ChangeTrackOrderRequest, MoveTrackToPlaylistRequest, @@ -47,11 +44,11 @@ export default class PlaylistsService extends HTTPClient implements PlaylistsCli } public updatePlaylist(playlistId: Playlist['id'], params: UpdatePlaylistRequestParams) { - return this.patch(`${this.ROUTE_V1}/${playlistId}`, params); + return this.patch(`${this.ROUTE_V1}/${playlistId}`, params); } public removePlaylist(params: RemovePlaylistRequestBody) { - return this.delete(`${this.ROUTE_V1}`, { + return this.delete(`${this.ROUTE_V1}`, { data: params, }); } @@ -67,9 +64,7 @@ export default class PlaylistsService extends HTTPClient implements PlaylistsCli } public removeTrackFromPlaylist({ playlistId, trackId }: RemoveTrackFromPlaylistRequestParams) { - return this.delete( - `${this.ROUTE_V1}/${playlistId}/tracks/${trackId}` - ); + return this.delete(`${this.ROUTE_V1}/${playlistId}/tracks/${trackId}`); } public changeTrackOrderInPlaylist({ playlistId, trackId, ...body }: ChangeTrackOrderRequest) { diff --git a/src/shared/api/http/types/@enums.ts b/src/shared/api/http/types/@enums.ts index 71127591..ec21976e 100644 --- a/src/shared/api/http/types/@enums.ts +++ b/src/shared/api/http/types/@enums.ts @@ -135,6 +135,11 @@ export enum TokenClaim { AUTHORITY_TIER = 'AUTHORITY_TIER', } +export enum AvatarCompositionType { + SINGLE_BODY = 'SINGLE_BODY', + BODY_WITH_FACE = 'BODY_WITH_FACE', +} + export enum AuthorityTier { FM = 'FM', // Full Crew (지갑인증 - 정회원) AM = 'AM', // Associate Crew (지갑인증 x - 준회원) diff --git a/src/shared/api/http/types/@shared.ts b/src/shared/api/http/types/@shared.ts index d82c0d68..26feb18d 100644 --- a/src/shared/api/http/types/@shared.ts +++ b/src/shared/api/http/types/@shared.ts @@ -23,6 +23,7 @@ export enum ErrorCode { ALREADY_REGISTERED = 'DJ-001', // 이미 DJ로 등록됨 QUEUE_CLOSED = 'DJ-002', // DJ 대기열이 닫혀 있음 EMPTY_PLAYLIST = 'DJ-003', // 비어있는 재생목록은 등록할 수 없음 + DJ_NOT_FOUND = 'DJ-004', // DJ 대기열에서 해당 DJ를 찾을 수 없음 // GradeException MANAGER_GRADE_REQUIRED = 'GRD-001', // 이 작업을 수행하려면 관리자 등급이 필요함 @@ -36,9 +37,8 @@ export enum ErrorCode { ALREADY_TERMINATED = 'PTR-002', // 이미 종료된 파티룸 EXCEEDED_LIMIT = 'PTR-003', // 파티룸 정원 초과 ACTIVE_ANOTHER_ROOM = 'PTR-004', // 이미 다른 파티룸에 활성화되어 있음 - CACHE_MISS_SESSION = 'PTR-005', // 세션 ID에 대한 캐시 데이터가 없음 - RESTRICTED_AUTHORITY = 'PTR-006', // 권한이 제한됨 (e.g. 지갑 인증 유저만 파티룸 생성 가능) - ALREADY_HOST = 'PTR-007', // 이미 다른 파티룸의 호스트임 + RESTRICTED_AUTHORITY = 'PTR-005', // 권한이 제한됨 (e.g. 지갑 인증 유저만 파티룸 생성 가능) + ALREADY_HOST = 'PTR-006', // 이미 다른 파티룸의 호스트임 // PenaltyException PERMANENT_EXPULSION = 'PNT-001', // 영구적으로 추방된 사용자 diff --git a/src/shared/api/http/types/partyrooms.ts b/src/shared/api/http/types/partyrooms.ts index 73816f21..f786a7fe 100644 --- a/src/shared/api/http/types/partyrooms.ts +++ b/src/shared/api/http/types/partyrooms.ts @@ -1,5 +1,5 @@ import { - AuthorityTier, + AvatarCompositionType, GradeType, MotionType, PenaltyType, @@ -69,9 +69,10 @@ export type PartyroomCrew = { crewId: number; nickname: string; gradeType: GradeType; + avatarCompositionType: AvatarCompositionType; avatarBodyUri: string; /** - * not combinable body일 경우 빈 문자열 + * SINGLE_BODY일 경우 빈 문자열 */ avatarFaceUri: string; avatarIconUri: string; @@ -130,19 +131,6 @@ export type GetSetUpInfoResponse = { }; }; -export type GetCrewsPayload = { - partyroomId: number; -}; - -export type PartyroomCrewSummary = { - uid: string; - authorityTier: AuthorityTier; - crewId: number; - nickname: string; - gradeType: GradeType; - avatarIconUri: string; -}; - export type GetDjingQueuePayload = { partyroomId: number; }; @@ -173,7 +161,7 @@ export type DjingQueue = { /** * 본인이 대기열에 등록되었는지 여부 */ - isRegistered: boolean; + registered: boolean; playback?: { name: string; thumbnailImage: string; @@ -207,10 +195,6 @@ export type EnterResponse = { gradeType: GradeType; }; -export type GetRoomIdByDomainResponse = { - partyroomId: number; -}; - export type ExitPayload = { partyroomId: number; }; @@ -226,16 +210,19 @@ export type ReactionPayload = { reactionType: ReactionType; }; -export type GetRoomIdByDomainPayload = { - domain: string; -}; - -export type EnterByLinkPayload = { +export type GetPartyroomByLinkPayload = { linkDomain: string; }; -export type EnterByLinkResponse = { +export type GetPartyroomByLinkResponse = { partyroomId: number; + title: string; + introduction: string; + playback: { + name: string; + thumbnailImage: string; + } | null; + crewCount: number; }; export type ReactionResponse = { @@ -295,10 +282,6 @@ export interface PartyroomsClient { * 파티룸 초기화 정보 조회 */ getSetupInfo: (payload: GetSetupInfoPayload) => Promise; - /** - * 우측 사이드 바의 ‘전체’ 탭을 눌렀을 시 호출하는 현재 파티룸 내의 파티 멤버 목록 조회 - */ - getCrews: (payload: GetCrewsPayload) => Promise; /** * DJ 대기열 조회 */ @@ -329,13 +312,9 @@ export interface PartyroomsClient { */ adjustGrade: (payload: AdjustGradePayload) => Promise; /** - * 공유 링크 입장 - */ - getRoomIdByDomain: (payload: GetRoomIdByDomainPayload) => Promise; - /** - * 링크로 파티룸 입장 (게스트 세션 생성) + * 공유 링크로 파티룸 정보 조회 */ - enterByLink: (payload: EnterByLinkPayload) => Promise; + getPartyroomByLink: (payload: GetPartyroomByLinkPayload) => Promise; /** * 패널티 목록 조회 */ diff --git a/src/shared/api/http/types/playlists.ts b/src/shared/api/http/types/playlists.ts index 45ea6bcc..2c506fa0 100644 --- a/src/shared/api/http/types/playlists.ts +++ b/src/shared/api/http/types/playlists.ts @@ -23,7 +23,7 @@ export interface GetTracksOfPlaylistParameters { */ export interface PlaylistTrack { trackId: number; - linkId: number; + linkId: string; name: string; orderNumber: number; duration: string; @@ -73,27 +73,14 @@ export interface AddTrackToPlaylistRequestBody { export interface RemovePlaylistRequestBody { playlistIds: number[]; } -export interface RemovePlaylistResponse { - playlistIds: number[]; -} - export interface UpdatePlaylistRequestParams { name: string; } -export interface UpdatePlaylistResponse { - id: number; - name: string; -} - export interface RemoveTrackFromPlaylistRequestParams { playlistId: number; trackId: number; } -export interface RemoveTrackFromPlaylistResponse { - listIds: number[]; -} - export type ChangeTrackOrderRequest = { playlistId: number; trackId: number; @@ -113,8 +100,8 @@ export interface PlaylistsClient { updatePlaylist: ( playlistId: Playlist['id'], params: UpdatePlaylistRequestParams - ) => Promise; - removePlaylist: (params: RemovePlaylistRequestBody) => Promise; + ) => Promise; + removePlaylist: (params: RemovePlaylistRequestBody) => Promise; getTracksOfPlaylist: ( playlistId: Playlist['id'], params?: GetTracksOfPlaylistParameters @@ -123,9 +110,7 @@ export interface PlaylistsClient { playlistId: Playlist['id'], params: AddTrackToPlaylistRequestBody ) => Promise; - removeTrackFromPlaylist: ( - params: RemoveTrackFromPlaylistRequestParams - ) => Promise; + removeTrackFromPlaylist: (params: RemoveTrackFromPlaylistRequestParams) => Promise; changeTrackOrderInPlaylist: (request: ChangeTrackOrderRequest) => Promise; moveTrackToPlaylist: (request: MoveTrackToPlaylistRequest) => Promise; } diff --git a/src/shared/api/http/types/users.ts b/src/shared/api/http/types/users.ts index 01434ba8..112e591b 100644 --- a/src/shared/api/http/types/users.ts +++ b/src/shared/api/http/types/users.ts @@ -1,4 +1,4 @@ -import { ActivityType, AuthorityTier, ObtainmentType } from './@enums'; +import { ActivityType, AuthorityTier, AvatarCompositionType, ObtainmentType } from './@enums'; export interface SignInRequest { oauth2Provider: OAuth2Provider; @@ -25,6 +25,7 @@ export interface ActivitySummary { export interface GetMyProfileSummaryResponse { nickname: string; introduction?: string; + avatarCompositionType: AvatarCompositionType; avatarBodyUri: string; avatarFaceUri: string; avatarIconUri: string; @@ -41,16 +42,12 @@ export interface InitiateLoginRequest { codeVerifier: string; } -export type InitiateLoginResponse = - | { - success: true; - authUrl: string; - state: string; - } - | { - success: false; - message: string; - }; +export type InitiateLoginResponse = { + authUrl: string; + state: string; + provider: string; + expiresIn: number; +}; export interface TokenExchangeRequest { provider: OAuth2Provider; @@ -59,20 +56,11 @@ export interface TokenExchangeRequest { state: string; } -export type TokenExchangeResponse = - | { - success: true; - user: { - id: string; - email: string; - name: string; - picture: string; - }; - } - | { - success: false; - message: string; - }; +export type TokenExchangeResponse = { + tokenType: string; + expiresIn: number; + issuedAt: string; +}; export interface AuthCallbackParams { code?: string; diff --git a/src/shared/api/websocket/client.test.ts b/src/shared/api/websocket/client.test.ts new file mode 100644 index 00000000..76d24522 --- /dev/null +++ b/src/shared/api/websocket/client.test.ts @@ -0,0 +1,213 @@ +vi.mock('@stomp/stompjs', () => { + return { + Client: vi.fn(function (this: any, config: any) { + this.connected = false; + this.activate = vi.fn(); + this.deactivate = vi.fn(); + this.subscribe = vi.fn((dest: string) => ({ + id: `sub-${dest}`, + unsubscribe: vi.fn(), + destination: dest, + })); + this.publish = vi.fn(); + this.__config = config; + }), + }; +}); + +vi.mock('@/shared/lib/functions/log/logger', () => ({ + specificLog: vi.fn(), + warnLog: vi.fn(), +})); + +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + default: () => (fn: any) => fn, +})); + +import SocketClient from './client'; + +function getStompClient(socketClient: SocketClient): any { + return (socketClient as any).client; +} + +function triggerConnect(socketClient: SocketClient) { + const stompClient = getStompClient(socketClient); + stompClient.connected = true; + stompClient.__config.onConnect(); +} + +describe('SocketClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('connect', () => { + test('연결 안 됨 → activate()를 호출한다', () => { + const sc = new SocketClient(); + sc.connect(); + expect(getStompClient(sc).activate).toHaveBeenCalled(); + }); + + test('이미 연결됨 → activate()를 호출하지 않는다', () => { + const sc = new SocketClient(); + getStompClient(sc).connected = true; + sc.connect(); + expect(getStompClient(sc).activate).not.toHaveBeenCalled(); + }); + }); + + describe('disconnect', () => { + test('연결됨 → deactivate()를 호출한다', async () => { + const sc = new SocketClient(); + getStompClient(sc).connected = true; + await sc.disconnect(); + expect(getStompClient(sc).deactivate).toHaveBeenCalled(); + }); + + test('연결 안 됨 → deactivate()를 호출하지 않는다', async () => { + const sc = new SocketClient(); + await sc.disconnect(); + expect(getStompClient(sc).deactivate).not.toHaveBeenCalled(); + }); + }); + + describe('onConnect', () => { + test('이미 연결 상태 → 콜백을 즉시 실행한다', () => { + const sc = new SocketClient(); + getStompClient(sc).connected = true; + const callback = vi.fn(); + + sc.onConnect(callback); + + expect(callback).toHaveBeenCalled(); + }); + + test('미연결 상태 → 큐에 추가 후 연결 시 실행한다', () => { + const sc = new SocketClient(); + const callback = vi.fn(); + + sc.onConnect(callback); + expect(callback).not.toHaveBeenCalled(); + + triggerConnect(sc); + expect(callback).toHaveBeenCalled(); + }); + + test('once: true + 이미 연결 → 즉시 실행하고 큐에 추가하지 않는다', () => { + const sc = new SocketClient(); + getStompClient(sc).connected = true; + const callback = vi.fn(); + + sc.onConnect(callback, { once: true }); + expect(callback).toHaveBeenCalledTimes(1); + + // handleConnect를 다시 호출해도 once 콜백은 다시 실행되지 않아야 한다 + callback.mockClear(); + triggerConnect(sc); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('handleConnect (내부)', () => { + test('큐의 모든 콜백을 실행하고 once 항목을 제거한다', () => { + const sc = new SocketClient(); + const persistent = vi.fn(); + const once = vi.fn(); + + sc.onConnect(persistent); + sc.onConnect(once, { once: true }); + + triggerConnect(sc); + expect(persistent).toHaveBeenCalledTimes(1); + expect(once).toHaveBeenCalledTimes(1); + + // 두 번째 connect + persistent.mockClear(); + once.mockClear(); + triggerConnect(sc); + expect(persistent).toHaveBeenCalledTimes(1); + expect(once).not.toHaveBeenCalled(); + }); + }); + + describe('subscribe', () => { + test('onConnect를 경유하여 client.subscribe를 호출하고 subscriptions에 추가한다', () => { + const sc = new SocketClient(); + const handler = vi.fn(); + + sc.subscribe('/sub/test' as any, handler); + triggerConnect(sc); + + expect(getStompClient(sc).subscribe).toHaveBeenCalledWith('/sub/test', handler); + expect(sc.subscriptions).toHaveLength(1); + expect(sc.subscriptions[0].destination).toBe('/sub/test'); + }); + }); + + describe('unsubscribe', () => { + test('연결 안 됨 → 아무 동작도 하지 않는다', () => { + const sc = new SocketClient(); + sc.unsubscribe('/sub/test' as any); + // 에러 없이 통과 + }); + + test('해당 destination이 없으면 아무 동작도 하지 않는다', () => { + const sc = new SocketClient(); + triggerConnect(sc); + sc.unsubscribe('/sub/nonexistent' as any); + // 에러 없이 통과 + }); + + test('해당 destination이 있으면 해제하고 배열에서 제거한다', () => { + const sc = new SocketClient(); + sc.subscribe('/sub/room' as any, vi.fn()); + triggerConnect(sc); + + expect(sc.subscriptions).toHaveLength(1); + const unsubFn = sc.subscriptions[0].unsubscribe; + + sc.unsubscribe('/sub/room' as any); + + expect(unsubFn).toHaveBeenCalled(); + expect(sc.subscriptions).toHaveLength(0); + }); + }); + + describe('unsubscribeAll', () => { + test('모든 구독을 해제하고 배열을 초기화한다', () => { + const sc = new SocketClient(); + sc.subscribe('/sub/a' as any, vi.fn()); + sc.subscribe('/sub/b' as any, vi.fn()); + triggerConnect(sc); + + expect(sc.subscriptions).toHaveLength(2); + const unsub0 = sc.subscriptions[0].unsubscribe; + const unsub1 = sc.subscriptions[1].unsubscribe; + + sc.unsubscribeAll(); + + expect(unsub0).toHaveBeenCalled(); + expect(unsub1).toHaveBeenCalled(); + expect(sc.subscriptions).toHaveLength(0); + }); + }); + + describe('send', () => { + test('onConnect({ once: true })를 경유하여 client.publish를 호출한다', () => { + const sc = new SocketClient(); + sc.send('/pub/test' as any, { data: 'hello' }); + + triggerConnect(sc); + + expect(getStompClient(sc).publish).toHaveBeenCalledWith({ + destination: '/pub/test', + body: JSON.stringify({ data: 'hello' }), + }); + }); + }); +}); diff --git a/src/shared/api/websocket/types/partyroom.ts b/src/shared/api/websocket/types/partyroom.ts index 15a2cc32..4cadfca9 100644 --- a/src/shared/api/websocket/types/partyroom.ts +++ b/src/shared/api/websocket/types/partyroom.ts @@ -1,46 +1,95 @@ -import { PartyroomCrew } from '@/shared/api/http/types/partyrooms'; import { - AccessType, + AvatarCompositionType, GradeType, MotionType, PenaltyType, ReactionType, } from '../../http/types/@enums'; -/** - * 파티룸 폐쇄 이벤트 - */ -export type PartyroomCloseEvent = { - eventType: PartyroomEventType.PARTYROOM_CLOSE; -}; - -// 재생 비활성화 이벤트 -export type PartyroomDeactivationEvent = { - eventType: PartyroomEventType.PARTYROOM_DEACTIVATION; -}; - -// 멤버 출입 이벤트 -export type PartyroomAccessEvent = - | { - eventType: PartyroomEventType.PARTYROOM_ACCESS; - accessType: AccessType.ENTER; - crew: PartyroomCrew; - } - | { - eventType: PartyroomEventType.PARTYROOM_ACCESS; - accessType: AccessType.EXIT; - crew: Pick; - }; - -// 공지사항 변동 이벤트 -export type PartyroomNoticeEvent = { - eventType: PartyroomEventType.PARTYROOM_NOTICE; +// ────────────────────────────────────────────── +// 공통 타입 +// ────────────────────────────────────────────── + +/** 모든 서버→클라이언트 브로드캐스트 메시지의 공통 필드 */ +type WebSocketEventBase = { + partyroomId: number; + id: string; // UUID v4 — 멱등성 처리용 + timestamp: number; // Unix epoch ms +}; + +/** 아바타 중첩 구조 (crew_entered, crew_profile_changed 등에서 사용) */ +export type CrewAvatar = { + avatarCompositionType: AvatarCompositionType; + avatarBodyUri: string; + avatarFaceUri: string | null; + avatarIconUri: string; + combinePositionX: number; + combinePositionY: number; + offsetX: number; + offsetY: number; + scale: number; +}; + +// ────────────────────────────────────────────── +// 이벤트 enum +// ────────────────────────────────────────────── + +export enum PartyroomEventType { + PARTYROOM_CLOSED = 'PARTYROOM_CLOSED', + PLAYBACK_DEACTIVATED = 'PLAYBACK_DEACTIVATED', + CREW_ENTERED = 'CREW_ENTERED', + CREW_EXITED = 'CREW_EXITED', + PARTYROOM_NOTICE_UPDATED = 'PARTYROOM_NOTICE_UPDATED', + REACTION_AGGREGATION_UPDATED = 'REACTION_AGGREGATION_UPDATED', + REACTION_PERFORMED = 'REACTION_PERFORMED', + PLAYBACK_STARTED = 'PLAYBACK_STARTED', + CHAT_MESSAGE_SENT = 'CHAT_MESSAGE_SENT', + CREW_GRADE_CHANGED = 'CREW_GRADE_CHANGED', + CREW_PENALIZED = 'CREW_PENALIZED', + CREW_PROFILE_CHANGED = 'CREW_PROFILE_CHANGED', + DJ_QUEUE_CHANGED = 'DJ_QUEUE_CHANGED', +} + +// ────────────────────────────────────────────── +// 이벤트 타입 정의 +// ────────────────────────────────────────────── + +/** 파티룸 폐쇄 이벤트 */ +export type PartyroomClosedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.PARTYROOM_CLOSED; +}; + +/** 재생 비활성화 이벤트 */ +export type PlaybackDeactivatedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.PLAYBACK_DEACTIVATED; +}; + +/** 크루 입장 이벤트 */ +export type CrewEnteredEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CREW_ENTERED; + crew: { + crewId: number; + gradeType: GradeType; + nickname: string; + avatar: CrewAvatar; + }; +}; + +/** 크루 퇴장 이벤트 */ +export type CrewExitedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CREW_EXITED; + crewId: number; +}; + +/** 공지사항 변동 이벤트 */ +export type PartyroomNoticeUpdatedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.PARTYROOM_NOTICE_UPDATED; content: string; }; -// 리액션 → 집계 변동 이벤트 -export type ReactionAggregationEvent = { - eventType: PartyroomEventType.REACTION_AGGREGATION; +/** 리액션 집계 변동 이벤트 */ +export type ReactionAggregationUpdatedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.REACTION_AGGREGATION_UPDATED; aggregation: { likeCount: number; dislikeCount: number; @@ -48,9 +97,9 @@ export type ReactionAggregationEvent = { }; }; -// 리액션 → 모션 변동 이벤트 -export type ReactionMotionEvent = { - eventType: PartyroomEventType.REACTION_MOTION; +/** 리액션 모션 이벤트 */ +export type ReactionPerformedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.REACTION_PERFORMED; motionType: MotionType; reactionType: ReactionType; crew: { @@ -58,35 +107,24 @@ export type ReactionMotionEvent = { }; }; -// 음악 정보 +/** 음악 정보 (reaction count 제거됨 — 클라이언트에서 초기값 0으로 설정) */ export type Playback = { linkId: string; name: string; duration: string; thumbnailImage: string; - likeCount: number; - dislikeCount: number; - grabCount: number; }; -// 재생(플레이백) 시작 이벤트 -export type PlaybackStartEvent = { - eventType: PartyroomEventType.PLAYBACK_START; +/** 재생 시작 이벤트 */ +export type PlaybackStartedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.PLAYBACK_STARTED; playback: Playback; crewId: number; }; -export type PlaybackSkipEvent = { - eventType: PartyroomEventType.PLAYBACK_SKIP; - // TODO: 임의로 작성. 실제 타입 확인 필요 -}; - -// 채팅 메시지 이벤트 -export type ChatEvent = { - eventType: PartyroomEventType.CHAT; - partyroomId: { - id: number; - }; +/** 채팅 메시지 이벤트 */ +export type ChatMessageSentEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CHAT_MESSAGE_SENT; crew: { crewId: number; }; @@ -96,9 +134,9 @@ export type ChatEvent = { }; }; -// 크루 등급 조정 이벤트 -export type CrewGradeEvent = { - eventType: PartyroomEventType.CREW_GRADE; +/** 크루 등급 조정 이벤트 */ +export type CrewGradeChangedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CREW_GRADE_CHANGED; adjuster: { crewId: number; }; @@ -109,11 +147,11 @@ export type CrewGradeEvent = { }; }; -// 크루 페널티 부과 이벤트 -export type CrewPenaltyEvent = { - eventType: PartyroomEventType.CREW_PENALTY; +/** 크루 페널티 부과 이벤트 */ +export type CrewPenalizedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CREW_PENALIZED; penaltyType: PenaltyType; - detail: string; // penaltyType이 CHAT_BAN_30_SECONDS 일 때는 삭제되어야 할 messageId, 그 외 경우에는 제재 이유 + detail: string; punisher: { crewId: number; }; @@ -122,48 +160,40 @@ export type CrewPenaltyEvent = { }; }; -// 크루 프로필(닉네임/아바타) 변경 이벤트 -export type CrewProfileEvent = { - eventType: PartyroomEventType.CREW_PROFILE; -} & Pick< - PartyroomCrew, - | 'crewId' - | 'nickname' - | 'avatarFaceUri' - | 'avatarBodyUri' - | 'avatarIconUri' - | 'combinePositionX' - | 'combinePositionY' - | 'offsetX' - | 'offsetY' - | 'scale' ->; +/** 크루 프로필 변경 이벤트 (아바타 필드 중첩) */ +export type CrewProfileChangedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.CREW_PROFILE_CHANGED; + crewId: number; + nickname: string; + avatar: CrewAvatar; +}; -export enum PartyroomEventType { - PARTYROOM_CLOSE = 'PARTYROOM_CLOSE', // FIXME: 맘대로 작성함. api 측과 enum 일치할지 확인 필요 - https://pfplay.slack.com/archives/C03Q28EAU66/p1737235619364459?thread_ts=1737203758.635399&cid=C03Q28EAU66 - PARTYROOM_DEACTIVATION = 'PARTYROOM_DEACTIVATION', - PARTYROOM_ACCESS = 'PARTYROOM_ACCESS', - PARTYROOM_NOTICE = 'PARTYROOM_NOTICE', - REACTION_AGGREGATION = 'REACTION_AGGREGATION', - REACTION_MOTION = 'REACTION_MOTION', - PLAYBACK_START = 'PLAYBACK_START', - PLAYBACK_SKIP = 'PLAYBACK_SKIP', - CHAT = 'CHAT', - CREW_GRADE = 'CREW_GRADE', - CREW_PENALTY = 'CREW_PENALTY', - CREW_PROFILE = 'CREW_PROFILE', -} +/** DJ 큐 변경 이벤트 */ +export type DjQueueChangedEvent = WebSocketEventBase & { + eventType: PartyroomEventType.DJ_QUEUE_CHANGED; + djs: Array<{ + crewId: number; + orderNumber: number; + nickname: string; + avatarIconUri: string; + }>; +}; + +// ────────────────────────────────────────────── +// 이벤트 유니온 +// ────────────────────────────────────────────── export type PartyroomSubEvent = - | PartyroomCloseEvent - | PartyroomDeactivationEvent - | PartyroomAccessEvent - | PartyroomNoticeEvent - | ReactionAggregationEvent - | ReactionMotionEvent - | PlaybackStartEvent - | PlaybackSkipEvent - | ChatEvent - | CrewGradeEvent - | CrewPenaltyEvent - | CrewProfileEvent; + | PartyroomClosedEvent + | PlaybackDeactivatedEvent + | CrewEnteredEvent + | CrewExitedEvent + | PartyroomNoticeUpdatedEvent + | ReactionAggregationUpdatedEvent + | ReactionPerformedEvent + | PlaybackStartedEvent + | ChatMessageSentEvent + | CrewGradeChangedEvent + | CrewPenalizedEvent + | CrewProfileChangedEvent + | DjQueueChangedEvent; diff --git a/src/shared/lib/chat/lib/chat.test.ts b/src/shared/lib/chat/lib/chat.test.ts new file mode 100644 index 00000000..66f5d389 --- /dev/null +++ b/src/shared/lib/chat/lib/chat.test.ts @@ -0,0 +1,92 @@ +import Chat from './chat'; + +describe('Chat', () => { + test('빈 초기 메시지로 생성', () => { + const chat = Chat.create([]); + expect(chat.getMessages()).toEqual([]); + }); + + test('초기 메시지와 함께 생성', () => { + const chat = Chat.create(['hello', 'world']); + expect(chat.getMessages()).toEqual(['hello', 'world']); + }); + + describe('appendMessage', () => { + test('메시지 추가', () => { + const chat = Chat.create([]); + chat.appendMessage('hello'); + chat.appendMessage('world'); + expect(chat.getMessages()).toEqual(['hello', 'world']); + }); + + test('메시지 추가 시 리스너에 알림', () => { + const chat = Chat.create([]); + const listener = vi.fn(); + chat.addMessageListener(listener); + + chat.appendMessage('hello'); + + expect(listener).toHaveBeenCalledWith('hello'); + }); + }); + + describe('updateMessage', () => { + test('조건에 맞는 메시지 업데이트', () => { + const chat = Chat.create([ + { id: 1, text: 'hello' }, + { id: 2, text: 'world' }, + ]); + + chat.updateMessage( + (msg) => msg.id === 1, + (msg) => ({ ...msg, text: 'updated' }) + ); + + expect(chat.getMessages()).toEqual([ + { id: 1, text: 'updated' }, + { id: 2, text: 'world' }, + ]); + }); + + test('업데이트 시 리스너에 알림', () => { + const chat = Chat.create([{ id: 1, text: 'hello' }]); + const listener = vi.fn(); + chat.addMessageListener(listener); + + chat.updateMessage( + (msg) => msg.id === 1, + (msg) => ({ ...msg, text: 'updated' }) + ); + + expect(listener).toHaveBeenCalledWith({ id: 1, text: 'updated' }); + }); + + test('조건에 맞는 메시지가 없으면 리스너 호출 안 됨', () => { + const chat = Chat.create([{ id: 1, text: 'hello' }]); + const listener = vi.fn(); + chat.addMessageListener(listener); + + chat.updateMessage( + (msg) => msg.id === 999, + (msg) => ({ ...msg, text: 'updated' }) + ); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + test('메시지와 리스너 모두 초기화', () => { + const chat = Chat.create(['hello', 'world']); + const listener = vi.fn(); + chat.addMessageListener(listener); + + chat.clear(); + + expect(chat.getMessages()).toEqual([]); + + chat.appendMessage('after clear'); + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/shared/lib/chat/lib/circular-buffer-adapter.test.ts b/src/shared/lib/chat/lib/circular-buffer-adapter.test.ts new file mode 100644 index 00000000..b525312c --- /dev/null +++ b/src/shared/lib/chat/lib/circular-buffer-adapter.test.ts @@ -0,0 +1,48 @@ +import CircularBuffer from './circular-buffer'; +import CircularBufferAdapter from './circular-buffer-adapter'; + +describe('CircularBufferAdapter', () => { + let adapter: CircularBufferAdapter; + + beforeEach(() => { + adapter = new CircularBufferAdapter(new CircularBuffer([], 10)); + }); + + test('append → list에 반영된다', () => { + adapter.append('hello'); + adapter.append('world'); + expect(adapter.list).toEqual(['hello', 'world']); + }); + + test('clear → list가 비어진다', () => { + adapter.append('a'); + adapter.append('b'); + adapter.clear(); + expect(adapter.list).toEqual([]); + }); + + test('update → 조건에 맞는 항목을 변경하고 변경된 항목을 반환한다', () => { + adapter.append('apple'); + adapter.append('banana'); + + const result = adapter.update( + (msg) => msg === 'apple', + () => 'APPLE' + ); + + expect(result).toBe('APPLE'); + expect(adapter.list).toEqual(['APPLE', 'banana']); + }); + + test('update → 조건에 맞는 항목이 없으면 undefined를 반환한다', () => { + adapter.append('apple'); + + const result = adapter.update( + (msg) => msg === 'cherry', + () => 'CHERRY' + ); + + expect(result).toBeUndefined(); + expect(adapter.list).toEqual(['apple']); + }); +}); diff --git a/src/shared/lib/chat/lib/observer-adapter.test.ts b/src/shared/lib/chat/lib/observer-adapter.test.ts new file mode 100644 index 00000000..dd529426 --- /dev/null +++ b/src/shared/lib/chat/lib/observer-adapter.test.ts @@ -0,0 +1,54 @@ +import ObserverAdapter from './observer-adapter'; +import Observer from '../../functions/observer'; +import type { ChatObserverEvent } from '../model/chat-message-listener.model'; + +describe('ObserverAdapter', () => { + let adapter: ObserverAdapter; + + beforeEach(() => { + adapter = new ObserverAdapter(new Observer>()); + }); + + test('register + notify → 리스너가 event.message만 수신한다', () => { + const listener = vi.fn(); + adapter.register(listener); + + adapter.notify({ type: 'add', message: 'hello' }); + + expect(listener).toHaveBeenCalledWith('hello'); + }); + + test('복수 리스너 등록 → 모두 호출된다', () => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + adapter.register(listener1); + adapter.register(listener2); + + adapter.notify({ type: 'add', message: 'test' }); + + expect(listener1).toHaveBeenCalledWith('test'); + expect(listener2).toHaveBeenCalledWith('test'); + }); + + test('deregisterAll → 이후 notify 시 리스너가 호출되지 않는다', () => { + const listener = vi.fn(); + adapter.register(listener); + adapter.deregisterAll(); + + adapter.notify({ type: 'add', message: 'hello' }); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('deregister → 참조 불일치로 리스너가 제거되지 않는다 (known limitation)', () => { + const listener = vi.fn(); + adapter.register(listener); + adapter.deregister(listener); + + // deregister는 새로운 래퍼 함수를 생성하므로 원래 래퍼와 참조가 다르다. + // 따라서 리스너가 제거되지 않고 여전히 호출된다. + adapter.notify({ type: 'update', message: 'still here' }); + + expect(listener).toHaveBeenCalledWith('still here'); + }); +}); diff --git a/src/shared/lib/decorators/mock/mock-resolve.decorator.test.ts b/src/shared/lib/decorators/mock/mock-resolve.decorator.test.ts new file mode 100644 index 00000000..c4f0d3ed --- /dev/null +++ b/src/shared/lib/decorators/mock/mock-resolve.decorator.test.ts @@ -0,0 +1,78 @@ +import MockResolve from './mock-resolve.decorator'; + +describe('MockResolve decorator', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.useFakeTimers(); + process.env = { ...originalEnv, NODE_ENV: 'test', NEXT_PUBLIC_USE_MOCK: 'true' }; + }); + + afterEach(() => { + vi.useRealTimers(); + process.env = originalEnv; + }); + + test('mock 환경에서 data resolve', async () => { + class TestService { + @MockResolve({ data: { id: 1 } }) + public async fetchData() { + return { id: 999 }; + } + } + + const service = new TestService(); + const promise = service.fetchData(); + vi.runAllTimers(); + await expect(promise).resolves.toEqual({ id: 1 }); + }); + + test('mock 환경에서 error reject', async () => { + class TestService { + @MockResolve({ error: new Error('mock error') }) + public async fetchData() { + return 'real'; + } + } + + const service = new TestService(); + const promise = service.fetchData(); + vi.runAllTimers(); + await expect(promise).rejects.toThrow('mock error'); + }); + + test('delay 옵션 적용', async () => { + class TestService { + @MockResolve({ data: 'delayed' }, { delay: 500 }) + public async fetchData() { + return 'real'; + } + } + + const service = new TestService(); + const callback = vi.fn(); + service.fetchData().then(callback); + + vi.advanceTimersByTime(499); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + await Promise.resolve(); + expect(callback).toHaveBeenCalledWith('delayed'); + }); + + test('production 환경에서는 원래 메서드 실행', async () => { + process.env.NODE_ENV = 'production'; + + class TestService { + @MockResolve({ data: 'mock' }) + public async fetchData() { + return 'real'; + } + } + + const service = new TestService(); + await expect(service.fetchData()).resolves.toBe('real'); + }); +}); diff --git a/src/shared/lib/decorators/mock/mock-return.decorator.test.ts b/src/shared/lib/decorators/mock/mock-return.decorator.test.ts new file mode 100644 index 00000000..929faf53 --- /dev/null +++ b/src/shared/lib/decorators/mock/mock-return.decorator.test.ts @@ -0,0 +1,65 @@ +import MockReturn from './mock-return.decorator'; + +describe('MockReturn decorator', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, NODE_ENV: 'test', NEXT_PUBLIC_USE_MOCK: 'true' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('mock 환경에서 data 반환', () => { + class TestService { + @MockReturn({ data: { id: 1, name: 'mock' } }) + public getData() { + return { id: 999, name: 'real' }; + } + } + + const service = new TestService(); + expect(service.getData()).toEqual({ id: 1, name: 'mock' }); + }); + + test('mock 환경에서 error throw', () => { + class TestService { + @MockReturn({ error: new Error('mock error') }) + public getData() { + return 'real'; + } + } + + const service = new TestService(); + expect(() => service.getData()).toThrow('mock error'); + }); + + test('production 환경에서는 원래 메서드 실행', () => { + process.env.NODE_ENV = 'production'; + + class TestService { + @MockReturn({ data: 'mock' }) + public getData() { + return 'real'; + } + } + + const service = new TestService(); + expect(service.getData()).toBe('real'); + }); + + test('NEXT_PUBLIC_USE_MOCK이 false면 원래 메서드 실행', () => { + process.env.NEXT_PUBLIC_USE_MOCK = 'false'; + + class TestService { + @MockReturn({ data: 'mock' }) + public getData() { + return 'real'; + } + } + + const service = new TestService(); + expect(service.getData()).toBe('real'); + }); +}); diff --git a/src/shared/lib/decorators/singleton/singleton.decorator.test.ts b/src/shared/lib/decorators/singleton/singleton.decorator.test.ts new file mode 100644 index 00000000..14ac30e3 --- /dev/null +++ b/src/shared/lib/decorators/singleton/singleton.decorator.test.ts @@ -0,0 +1,45 @@ +import Singleton from './singleton.decorator'; + +describe('Singleton decorator', () => { + test('여러 번 인스턴스화해도 동일 인스턴스 반환', () => { + @Singleton + class TestService { + public value = 0; + } + + const a = new TestService(); + const b = new TestService(); + + expect(a).toBe(b); + }); + + test('첫 번째 인스턴스의 상태가 유지됨', () => { + @Singleton + class Counter { + public count = 0; + public increment() { + this.count++; + } + } + + const first = new Counter(); + first.increment(); + first.increment(); + + const second = new Counter(); + expect(second.count).toBe(2); + }); + + test('프로토타입 체인이 유지됨', () => { + @Singleton + class MyClass { + public greet() { + return 'hello'; + } + } + + const instance = new MyClass(); + expect(instance.greet()).toBe('hello'); + expect(instance).toBeInstanceOf(MyClass); + }); +}); diff --git a/src/shared/lib/decorators/skip-global-error-handling/skip-global-error-handling.decorator.test.ts b/src/shared/lib/decorators/skip-global-error-handling/skip-global-error-handling.decorator.test.ts new file mode 100644 index 00000000..0c195b57 --- /dev/null +++ b/src/shared/lib/decorators/skip-global-error-handling/skip-global-error-handling.decorator.test.ts @@ -0,0 +1,87 @@ +import SkipGlobalErrorHandling, { + shouldSkipGlobalErrorHandling, +} from './skip-global-error-handling.decorator'; + +describe('SkipGlobalErrorHandling decorator', () => { + test('에러 발생 시 skipGlobalErrorHandling 프로퍼티 추가', async () => { + class TestService { + @SkipGlobalErrorHandling() + public async failingMethod() { + throw new Error('test error'); + } + } + + const service = new TestService(); + try { + await service.failingMethod(); + } catch (error) { + expect(shouldSkipGlobalErrorHandling(error)).toBe(true); + } + }); + + test('when: false이면 프로퍼티 추가 안 됨', async () => { + class TestService { + @SkipGlobalErrorHandling({ when: false }) + public async failingMethod() { + throw new Error('test error'); + } + } + + const service = new TestService(); + try { + await service.failingMethod(); + } catch (error) { + expect(shouldSkipGlobalErrorHandling(error)).toBe(false); + } + }); + + test('when 함수가 조건부로 프로퍼티 추가', async () => { + class TestService { + @SkipGlobalErrorHandling({ when: (err) => err.message === 'skip me' }) + public async failingMethod(msg: string) { + throw new Error(msg); + } + } + + const service = new TestService(); + + try { + await service.failingMethod('skip me'); + } catch (error) { + expect(shouldSkipGlobalErrorHandling(error)).toBe(true); + } + + try { + await service.failingMethod('do not skip'); + } catch (error) { + expect(shouldSkipGlobalErrorHandling(error)).toBe(false); + } + }); + + test('성공 시 원래 반환값 유지', async () => { + class TestService { + @SkipGlobalErrorHandling() + public async successMethod() { + return 'ok'; + } + } + + const service = new TestService(); + await expect(service.successMethod()).resolves.toBe('ok'); + }); +}); + +describe('shouldSkipGlobalErrorHandling', () => { + test('일반 Error는 false', () => { + expect(shouldSkipGlobalErrorHandling(new Error('normal'))).toBe(false); + }); + + test('null/undefined는 false', () => { + expect(shouldSkipGlobalErrorHandling(null)).toBe(false); + expect(shouldSkipGlobalErrorHandling(undefined)).toBe(false); + }); + + test('문자열은 false', () => { + expect(shouldSkipGlobalErrorHandling('error string')).toBe(false); + }); +}); diff --git a/src/shared/lib/functions/capture-dom.test.ts b/src/shared/lib/functions/capture-dom.test.ts new file mode 100644 index 00000000..95b7f607 --- /dev/null +++ b/src/shared/lib/functions/capture-dom.test.ts @@ -0,0 +1,40 @@ +import { captureDOMToBlob, convertToFormData } from './capture-dom'; + +vi.mock('html2canvas', () => ({ + default: vi.fn().mockResolvedValue({ + toBlob: (cb: (blob: Blob | null) => void) => { + cb(new Blob(['test'], { type: 'image/webp' })); + }, + }), +})); + +describe('capture-dom', () => { + describe('captureDOMToBlob', () => { + test('DOM이 없으면 에러 throw', async () => { + const ref = { current: null }; + await expect(captureDOMToBlob(ref)).rejects.toThrow('DOM is not found'); + }); + + test('DOM을 캡처하여 Blob 반환', async () => { + const mockElement = document.createElement('div'); + vi.spyOn(mockElement, 'querySelectorAll').mockReturnValue( + [] as unknown as NodeListOf + ); + const ref = { current: mockElement }; + + const blob = await captureDOMToBlob(ref); + + expect(blob).toBeInstanceOf(Blob); + }); + }); + + describe('convertToFormData', () => { + test('Blob을 FormData로 변환', () => { + const blob = new Blob(['test'], { type: 'image/webp' }); + const formData = convertToFormData(blob); + + expect(formData).toBeInstanceOf(FormData); + expect(formData.get('image')).toBeInstanceOf(Blob); + }); + }); +}); diff --git a/src/shared/lib/functions/clone-deep.test.ts b/src/shared/lib/functions/clone-deep.test.ts index 82217447..78973735 100644 --- a/src/shared/lib/functions/clone-deep.test.ts +++ b/src/shared/lib/functions/clone-deep.test.ts @@ -27,4 +27,23 @@ describe('cloneDeep', () => { expect(isOneDepthRefSame).toEqual(false); expect(isTwoDepthRefSame).toEqual(false); }); + + test('원시값 그대로 반환', () => { + expect(cloneDeep(42)).toBe(42); + expect(cloneDeep('hello')).toBe('hello'); + expect(cloneDeep(null)).toBe(null); + expect(cloneDeep(undefined)).toBe(undefined); + expect(cloneDeep(true)).toBe(true); + }); + + test('원본 변경이 클론에 영향 없음', () => { + const original = { a: { b: 1 }, c: [1, 2] }; + const copy = cloneDeep(original); + + original.a.b = 999; + original.c.push(3); + + expect(copy.a.b).toBe(1); + expect(copy.c).toEqual([1, 2]); + }); }); diff --git a/src/shared/lib/functions/cn.test.ts b/src/shared/lib/functions/cn.test.ts new file mode 100644 index 00000000..4b3e24f1 --- /dev/null +++ b/src/shared/lib/functions/cn.test.ts @@ -0,0 +1,29 @@ +import { cn } from './cn'; + +describe('cn', () => { + test('단일 클래스', () => { + expect(cn('text-red-500')).toBe('text-red-500'); + }); + + test('여러 클래스 병합', () => { + expect(cn('px-2', 'py-1')).toBe('px-2 py-1'); + }); + + test('조건부 클래스', () => { + const isHidden = false; + const isVisible = true; + expect(cn('base', isHidden && 'hidden', isVisible && 'visible')).toBe('base visible'); + }); + + test('tailwind 충돌 클래스 병합 (후자 우선)', () => { + expect(cn('px-2', 'px-4')).toBe('px-4'); + }); + + test('빈 입력', () => { + expect(cn()).toBe(''); + }); + + test('undefined/null 무시', () => { + expect(cn('base', undefined, null)).toBe('base'); + }); +}); diff --git a/src/shared/lib/functions/combine-ref.test.ts b/src/shared/lib/functions/combine-ref.test.ts index 4a8a009a..90926b31 100644 --- a/src/shared/lib/functions/combine-ref.test.ts +++ b/src/shared/lib/functions/combine-ref.test.ts @@ -5,7 +5,7 @@ describe('combineRef', () => { test('모든 ref가 정상적으로 합쳐져야 한다.', () => { const fakeRef1: RefObject = { current: null }; const fakeRef2: RefObject = { current: null }; - const fakeRef3: RefCallback = jest.fn(); + const fakeRef3: RefCallback = vi.fn(); const combinedRef = combineRef([fakeRef1, fakeRef2, fakeRef3]); diff --git a/src/shared/lib/functions/delay.test.ts b/src/shared/lib/functions/delay.test.ts new file mode 100644 index 00000000..b536d1ee --- /dev/null +++ b/src/shared/lib/functions/delay.test.ts @@ -0,0 +1,41 @@ +import { delay } from './delay'; + +describe('delay', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('지정된 시간 후 resolve', async () => { + const callback = vi.fn(); + + delay(1000).then(callback); + + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('0ms delay도 정상 동작', async () => { + const callback = vi.fn(); + + delay(0).then(callback); + + vi.advanceTimersByTime(0); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('Promise 반환', async () => { + const promise = delay(100); + vi.advanceTimersByTime(100); + await expect(promise).resolves.toBeUndefined(); + }); +}); diff --git a/src/shared/lib/functions/event-emitter.test.ts b/src/shared/lib/functions/event-emitter.test.ts new file mode 100644 index 00000000..9afe383a --- /dev/null +++ b/src/shared/lib/functions/event-emitter.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from './event-emitter'; + +describe('EventEmitter', () => { + test('on으로 등록한 콜백이 emit 시 호출됨', () => { + const emitter = new EventEmitter(); + const callback = vi.fn(); + + emitter.on('test', callback); + emitter.emit('test'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('같은 이벤트에 여러 콜백 등록 가능', () => { + const emitter = new EventEmitter(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + emitter.on('test', cb1); + emitter.on('test', cb2); + emitter.emit('test'); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + test('다른 이벤트의 콜백은 호출되지 않음', () => { + const emitter = new EventEmitter(); + const callback = vi.fn(); + + emitter.on('other', callback); + emitter.emit('test'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('on 반환값으로 구독 해제 가능', () => { + const emitter = new EventEmitter(); + const callback = vi.fn(); + + const unsubscribe = emitter.on('test', callback); + unsubscribe(); + emitter.emit('test'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('구독 해제 후에도 다른 콜백은 정상 동작', () => { + const emitter = new EventEmitter(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + const unsub1 = emitter.on('test', cb1); + emitter.on('test', cb2); + unsub1(); + emitter.emit('test'); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + test('등록되지 않은 이벤트를 emit해도 에러 없음', () => { + const emitter = new EventEmitter(); + expect(() => emitter.emit('nonexistent')).not.toThrow(); + }); + + test('제네릭 타입 이벤트 지원', () => { + const emitter = new EventEmitter<'play' | 'pause'>(); + const callback = vi.fn(); + + emitter.on('play', callback); + emitter.emit('play'); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/functions/log/logger.test.ts b/src/shared/lib/functions/log/logger.test.ts index f5c2c6f8..4a5133fe 100644 --- a/src/shared/lib/functions/log/logger.test.ts +++ b/src/shared/lib/functions/log/logger.test.ts @@ -7,7 +7,7 @@ describe('logger', () => { const mockData = { key: 'value' }; beforeEach(() => { - consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(); }); afterEach(() => { diff --git a/src/shared/lib/functions/log/network-log.test.ts b/src/shared/lib/functions/log/network-log.test.ts new file mode 100644 index 00000000..1d72e91c --- /dev/null +++ b/src/shared/lib/functions/log/network-log.test.ts @@ -0,0 +1,73 @@ +vi.mock('./logger', () => ({ + infoLog: vi.fn(), + successLog: vi.fn(), + errorLog: vi.fn(), +})); + +import { infoLog, successLog, errorLog } from './logger'; +import { printRequestLog, printResponseLog, printErrorLog } from './network-log'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('network-log', () => { + describe('printRequestLog', () => { + test('클라이언트 환경에서 요청 바디 로그 출력', () => { + printRequestLog({ + method: 'post', + endPoint: '/api/users', + requestData: { name: 'test' }, + }); + + expect(infoLog).toHaveBeenCalledWith('POST /api/users [REQ BODY]', { name: 'test' }); + }); + + test('클라이언트 환경에서 요청 파라미터가 있으면 파라미터 로그도 출력', () => { + printRequestLog({ + method: 'get', + endPoint: '/api/users', + requestParams: { page: 1 }, + }); + + expect(infoLog).toHaveBeenCalledWith('GET /api/users [REQ PARAMS]', { page: 1 }); + }); + + test('빈 requestParams는 파라미터 로그 출력 안 함', () => { + printRequestLog({ + method: 'get', + endPoint: '/api/users', + requestParams: {}, + }); + + expect(infoLog).toHaveBeenCalledTimes(1); + expect(infoLog).toHaveBeenCalledWith('GET /api/users [REQ BODY]', undefined); + }); + }); + + describe('printResponseLog', () => { + test('클라이언트 환경에서 응답 로그 출력', () => { + printResponseLog({ + method: 'get', + endPoint: '/api/users', + response: { data: [] }, + }); + + expect(successLog).toHaveBeenCalledWith('GET /api/users [RES BODY]', { data: [] }); + }); + }); + + describe('printErrorLog', () => { + test('클라이언트 환경에서 에러 로그 출력', () => { + const error = new Error('fail'); + printErrorLog({ + method: 'post', + endPoint: '/api/users', + errorMessage: 'fail', + error, + }); + + expect(errorLog).toHaveBeenCalledWith('POST /api/users [ERR]', 'fail', error); + }); + }); +}); diff --git a/src/shared/lib/functions/log/with-debugger.test.ts b/src/shared/lib/functions/log/with-debugger.test.ts index a55c288e..5e95ec97 100644 --- a/src/shared/lib/functions/log/with-debugger.test.ts +++ b/src/shared/lib/functions/log/with-debugger.test.ts @@ -10,7 +10,7 @@ describe('withDebugger', () => { test('should call the function', () => { // Arrange const debugLevel = 0; - const fn = jest.fn(); + const fn = vi.fn(); const args = [1, 2, 3]; window.debugLevel = debugLevel + 1; @@ -24,7 +24,7 @@ describe('withDebugger', () => { test('should return the result of the function', () => { // Arrange const debugLevel = 0; - const fn = jest.fn().mockReturnValue('result'); + const fn = vi.fn().mockReturnValue('result'); const args = [1, 2, 3]; window.debugLevel = debugLevel + 1; @@ -40,7 +40,7 @@ describe('withDebugger', () => { test('should not call the function', () => { // Arrange const debugLevel = 1; - const fn = jest.fn(); + const fn = vi.fn(); const args = [1, 2, 3]; window.debugLevel = debugLevel; @@ -54,7 +54,7 @@ describe('withDebugger', () => { test('should return the fallback value', () => { // Arrange const debugLevel = 1; - const fn = jest.fn(); + const fn = vi.fn(); const args = [1, 2, 3]; const fallback = 'fallback'; window.debugLevel = debugLevel; @@ -70,7 +70,7 @@ describe('withDebugger', () => { test('should not call the function', () => { // Arrange const debugLevel = 2; - const fn = jest.fn(); + const fn = vi.fn(); const args = [1, 2, 3]; window.debugLevel = debugLevel - 1; @@ -90,7 +90,7 @@ describe('withDebugger', () => { test('should call the function', () => { // Arrange const debugLevel = 0; - const fn = jest.fn(); + const fn = vi.fn(); const args = [1, 2, 3]; window.debugLevel = debugLevel; diff --git a/src/shared/lib/functions/log/with-log.test.ts b/src/shared/lib/functions/log/with-log.test.ts new file mode 100644 index 00000000..c20e6595 --- /dev/null +++ b/src/shared/lib/functions/log/with-log.test.ts @@ -0,0 +1,59 @@ +vi.mock('./network-log', () => ({ + printRequestLog: vi.fn(), + printResponseLog: vi.fn(), + printErrorLog: vi.fn(), +})); + +vi.mock('@/shared/api/http/error/get-error-message', () => ({ + getErrorMessage: vi.fn((err: Error) => err.message), +})); + +import { printRequestLog, printResponseLog, printErrorLog } from './network-log'; +import withLog from './with-log'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('withLog', () => { + test('성공 시 요청/응답 로그 출력 후 결과 반환', async () => { + const fn = Object.defineProperty(async (a: number) => a * 2, 'name', { value: 'testFn' }); + const wrapped = withLog(fn, 'get'); + + const result = await wrapped(5); + + expect(result).toBe(10); + expect(printRequestLog).toHaveBeenCalledWith({ + method: 'get', + endPoint: 'testFn', + requestData: [5], + }); + expect(printResponseLog).toHaveBeenCalledWith({ + method: 'get', + endPoint: 'testFn', + response: 10, + }); + expect(printErrorLog).not.toHaveBeenCalled(); + }); + + test('에러 시 에러 로그 출력 후 에러 재throw', async () => { + const error = new Error('test error'); + const fn = Object.defineProperty( + async () => { + throw error; + }, + 'name', + { value: 'failFn' } + ); + const wrapped = withLog(fn, 'post'); + + await expect(wrapped()).rejects.toThrow('test error'); + expect(printErrorLog).toHaveBeenCalledWith({ + method: 'post', + endPoint: 'failFn', + errorMessage: 'test error', + error, + }); + expect(printResponseLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/lib/functions/merge-deep.test.ts b/src/shared/lib/functions/merge-deep.test.ts index 588ddc40..73a483f8 100644 --- a/src/shared/lib/functions/merge-deep.test.ts +++ b/src/shared/lib/functions/merge-deep.test.ts @@ -27,6 +27,34 @@ describe('mergeDeep', () => { expect(result).toStrictEqual(expected); }); + + test('source에만 있는 key 유지', () => { + const result = mergeDeep({ a: 1, b: 2 }, { a: 10 }); + + expect(result).toStrictEqual({ a: 10, b: 2 }); + }); + + test('override에만 있는 key 추가', () => { + const result = mergeDeep({ a: 1 }, { b: 2 } as Record); + + expect(result).toStrictEqual({ a: 1, b: 2 }); + }); + + test('배열은 merge하지 않고 교체', () => { + const result = mergeDeep({ items: [1, 2, 3] }, { items: [4, 5] }); + + expect(result).toStrictEqual({ items: [4, 5] }); + }); + + test('원본 mutation 없음 (immutability)', () => { + const initial = { a: { nested: 1 }, b: 2 }; + const override = { a: { nested: 99 } }; + const initialCopy = JSON.parse(JSON.stringify(initial)); + + mergeDeep(initial, override); + + expect(initial).toStrictEqual(initialCopy); + }); }); type MergeTestGroup = { diff --git a/src/shared/lib/functions/merge-deep.ts b/src/shared/lib/functions/merge-deep.ts index b4fe2f97..ae9ec866 100644 --- a/src/shared/lib/functions/merge-deep.ts +++ b/src/shared/lib/functions/merge-deep.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { PartialDeep } from 'type-fest'; import { cloneDeep } from './clone-deep'; import { isPlainObject } from './is-plain-object'; diff --git a/src/shared/lib/functions/observer.test.ts b/src/shared/lib/functions/observer.test.ts index 539e9228..c4e7d86c 100644 --- a/src/shared/lib/functions/observer.test.ts +++ b/src/shared/lib/functions/observer.test.ts @@ -3,7 +3,7 @@ import Observer from './observer'; describe('Observer', () => { test('구독자에게 올바른 데이터 전달', () => { const observer = new Observer(); - const mockListener = jest.fn(); + const mockListener = vi.fn(); observer.subscribe(mockListener); observer.notify(42); @@ -13,8 +13,8 @@ describe('Observer', () => { test('여러 구독자에게 알림 전송', () => { const observer = new Observer(); - const mockListener1 = jest.fn(); - const mockListener2 = jest.fn(); + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); observer.subscribe(mockListener1); observer.subscribe(mockListener2); @@ -26,7 +26,7 @@ describe('Observer', () => { test('구독 해제된 리스너는 호출되지 않아야 한다', () => { const observer = new Observer(); - const mockListener = jest.fn(); + const mockListener = vi.fn(); observer.subscribe(mockListener); observer.unsubscribe(mockListener); @@ -37,8 +37,8 @@ describe('Observer', () => { test('구독된 리스너만 호출되어야 한다', () => { const observer = new Observer(); - const mockListener1 = jest.fn(); - const mockListener2 = jest.fn(); + const mockListener1 = vi.fn(); + const mockListener2 = vi.fn(); observer.subscribe(mockListener1); observer.notify(1); diff --git a/src/shared/lib/functions/pkce.test.ts b/src/shared/lib/functions/pkce.test.ts new file mode 100644 index 00000000..e01d4bed --- /dev/null +++ b/src/shared/lib/functions/pkce.test.ts @@ -0,0 +1,105 @@ +import { + parseCallbackParams, + setStoredState, + getStoredState, + clearStoredState, + getStoredCodeVerifier, + clearStoredCodeVerifier, + createPKCEParams, +} from './pkce'; + +describe('PKCE', () => { + describe('parseCallbackParams', () => { + const originalLocation = window.location; + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + test('code와 state 파라미터 파싱', () => { + Object.defineProperty(window, 'location', { + value: { search: '?code=abc123&state=xyz' }, + writable: true, + }); + expect(parseCallbackParams()).toEqual({ + code: 'abc123', + state: 'xyz', + error: undefined, + error_description: undefined, + }); + }); + + test('에러 파라미터 파싱', () => { + Object.defineProperty(window, 'location', { + value: { search: '?error=access_denied&error_description=User+denied' }, + writable: true, + }); + const result = parseCallbackParams(); + expect(result.error).toBe('access_denied'); + expect(result.error_description).toBe('User denied'); + }); + + test('파라미터 없으면 undefined 필드 반환', () => { + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true, + }); + expect(parseCallbackParams()).toEqual({ + code: undefined, + state: undefined, + error: undefined, + error_description: undefined, + }); + }); + }); + + describe('Storage 함수', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('state 저장 → 조회 → 삭제 라이프사이클', () => { + expect(getStoredState()).toBeNull(); + + setStoredState('my-state'); + expect(getStoredState()).toBe('my-state'); + + clearStoredState(); + expect(getStoredState()).toBeNull(); + }); + + test('codeVerifier 조회 → 삭제 라이프사이클', async () => { + expect(getStoredCodeVerifier()).toBeNull(); + + await createPKCEParams(); + expect(getStoredCodeVerifier()).not.toBeNull(); + + clearStoredCodeVerifier(); + expect(getStoredCodeVerifier()).toBeNull(); + }); + }); + + describe('createPKCEParams', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('codeVerifier가 생성되고 storage에 저장됨', async () => { + const { codeVerifier } = await createPKCEParams(); + + expect(codeVerifier).toBeDefined(); + expect(typeof codeVerifier).toBe('string'); + expect(codeVerifier.length).toBeGreaterThan(0); + expect(getStoredCodeVerifier()).toBe(codeVerifier); + }); + + test('URL-safe Base64 형식 (+ / = 미포함)', async () => { + const { codeVerifier } = await createPKCEParams(); + + expect(codeVerifier).not.toMatch(/[+/=]/); + }); + }); +}); diff --git a/src/shared/lib/functions/repeat-animation-frame.test.ts b/src/shared/lib/functions/repeat-animation-frame.test.ts new file mode 100644 index 00000000..cf663b9e --- /dev/null +++ b/src/shared/lib/functions/repeat-animation-frame.test.ts @@ -0,0 +1,40 @@ +import { repeatAnimationFrame } from './repeat-animation-frame'; + +describe('repeatAnimationFrame', () => { + let rafCallback: FrameRequestCallback; + + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallback = cb; + return 0; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('repeat이 0이면 즉시 콜백 실행', () => { + const callback = vi.fn(); + repeatAnimationFrame(callback, 0); + expect(callback).toHaveBeenCalledTimes(1); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + }); + + test('repeat이 1이면 requestAnimationFrame 1회 후 콜백 실행', () => { + const callback = vi.fn(); + repeatAnimationFrame(callback, 1); + + expect(callback).not.toHaveBeenCalled(); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + + rafCallback(0); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('repeat이 음수면 즉시 콜백 실행', () => { + const callback = vi.fn(); + repeatAnimationFrame(callback, -1); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/functions/safe-decode-uri.test.ts b/src/shared/lib/functions/safe-decode-uri.test.ts index 9613b647..c6ed9e57 100644 --- a/src/shared/lib/functions/safe-decode-uri.test.ts +++ b/src/shared/lib/functions/safe-decode-uri.test.ts @@ -31,7 +31,7 @@ describe('safeDecodeURI', () => { const encodedURI = 'https://example.com/%E0%A4%A%20valid%20sequence'; // decodeURI를 모킹하여 URIError가 아닌 예외를 던지게 함 - jest.spyOn(global, 'decodeURI').mockImplementation(() => { + vi.spyOn(global, 'decodeURI').mockImplementation(() => { throw new Error('Non-URIError exception'); }); diff --git a/src/shared/lib/functions/silent.test.ts b/src/shared/lib/functions/silent.test.ts new file mode 100644 index 00000000..3e4f3f71 --- /dev/null +++ b/src/shared/lib/functions/silent.test.ts @@ -0,0 +1,55 @@ +import silent from './silent'; + +describe('silent', () => { + test('성공 시 true 반환', async () => { + const result = await silent(Promise.resolve('data')); + expect(result).toBe(true); + }); + + test('실패 시 false 반환', async () => { + const result = await silent(Promise.reject(new Error('fail'))); + expect(result).toBe(false); + }); + + test('성공 시 onSuccess 콜백 호출', async () => { + const onSuccess = vi.fn(); + await silent(Promise.resolve('data'), { onSuccess }); + expect(onSuccess).toHaveBeenCalledWith('data'); + }); + + test('실패 시 onError 콜백 호출', async () => { + const error = new Error('fail'); + const onError = vi.fn(); + await silent(Promise.reject(error), { onError }); + expect(onError).toHaveBeenCalledWith(error); + }); + + test('성공 시 onSettled 콜백 호출', async () => { + const onSettled = vi.fn(); + await silent(Promise.resolve('data'), { onSettled }); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + test('실패 시에도 onSettled 콜백 호출', async () => { + const onSettled = vi.fn(); + await silent(Promise.reject(new Error('fail')), { onSettled }); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + test('성공 시 onError 호출되지 않음', async () => { + const onError = vi.fn(); + await silent(Promise.resolve('data'), { onError }); + expect(onError).not.toHaveBeenCalled(); + }); + + test('실패 시 onSuccess 호출되지 않음', async () => { + const onSuccess = vi.fn(); + await silent(Promise.reject(new Error('fail')), { onSuccess }); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + test('옵션 없이도 정상 동작', async () => { + await expect(silent(Promise.resolve('data'))).resolves.toBe(true); + await expect(silent(Promise.reject(new Error('fail')))).resolves.toBe(false); + }); +}); diff --git a/src/shared/lib/functions/update.test.ts b/src/shared/lib/functions/update.test.ts index e97c029c..0afad3e2 100644 --- a/src/shared/lib/functions/update.test.ts +++ b/src/shared/lib/functions/update.test.ts @@ -31,4 +31,18 @@ describe('update', () => { expect(result).toEqual({ a: 2, b: 2 }); }); + + test('prev가 원시값이면 next로 대체', () => { + const result = update(10, 20 as unknown as number); + + expect(result).toBe(20); + }); + + test('prev가 undefined + 함수 next → 함수 실행', () => { + const next = (prev: undefined) => (prev === undefined ? 'created' : 'other'); + + const result = update(undefined, next); + + expect(result).toBe('created'); + }); }); diff --git a/src/shared/lib/hooks/use-click-outside.hook.test.tsx b/src/shared/lib/hooks/use-click-outside.hook.test.tsx new file mode 100644 index 00000000..9a297f79 --- /dev/null +++ b/src/shared/lib/hooks/use-click-outside.hook.test.tsx @@ -0,0 +1,72 @@ +import { renderHook, act } from '@testing-library/react'; +import useClickOutside from './use-click-outside.hook'; + +describe('useClickOutside', () => { + test('ref 외부 클릭 → callback 호출', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useClickOutside(callback)); + + // ref에 DOM 요소 연결 + const inside = document.createElement('div'); + document.body.appendChild(inside); + Object.defineProperty(result.current, 'current', { + value: inside, + writable: true, + }); + + // 외부 클릭 + const outside = document.createElement('div'); + document.body.appendChild(outside); + + act(() => { + outside.click(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // 정리 + document.body.removeChild(inside); + document.body.removeChild(outside); + }); + + test('ref 내부 클릭 → callback 호출 안 함', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useClickOutside(callback)); + + const inside = document.createElement('div'); + document.body.appendChild(inside); + Object.defineProperty(result.current, 'current', { + value: inside, + writable: true, + }); + + act(() => { + inside.click(); + }); + + expect(callback).not.toHaveBeenCalled(); + + document.body.removeChild(inside); + }); + + test('언마운트 시 이벤트 리스너 정리됨', () => { + const callback = vi.fn(); + const removeSpy = vi.spyOn(document, 'removeEventListener'); + + const { result, unmount } = renderHook(() => useClickOutside(callback)); + + const inside = document.createElement('div'); + document.body.appendChild(inside); + Object.defineProperty(result.current, 'current', { + value: inside, + writable: true, + }); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function)); + + removeSpy.mockRestore(); + document.body.removeChild(inside); + }); +}); diff --git a/src/shared/lib/hooks/use-debounce.hook.test.ts b/src/shared/lib/hooks/use-debounce.hook.test.ts new file mode 100644 index 00000000..6fa171d2 --- /dev/null +++ b/src/shared/lib/hooks/use-debounce.hook.test.ts @@ -0,0 +1,124 @@ +import { ChangeEvent } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './use-debounce.hook'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('value는 handleChange 호출 즉시 업데이트', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useDebounce(callback)); + + act(() => { + result.current.handleChange({ + target: { value: 'test' }, + } as ChangeEvent); + }); + + expect(result.current.value).toBe('test'); + }); + + test('callback은 기본 interval(500ms) 후 호출', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useDebounce(callback)); + + act(() => { + result.current.handleChange({ + target: { value: 'hello' }, + } as ChangeEvent); + }); + + expect(callback).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('hello'); + }); + + test('연속 입력 시 이전 타이머 취소, 마지막 값만 콜백', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useDebounce(callback)); + + act(() => { + result.current.handleChange({ + target: { value: 'h' }, + } as ChangeEvent); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + act(() => { + result.current.handleChange({ + target: { value: 'he' }, + } as ChangeEvent); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + act(() => { + result.current.handleChange({ + target: { value: 'hel' }, + } as ChangeEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('hel'); + }); + + test('커스텀 interval 적용', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useDebounce(callback, { interval: 1000 })); + + act(() => { + result.current.handleChange({ + target: { value: 'test' }, + } as ChangeEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(callback).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('test'); + }); + + test('callback이 undefined여도 에러 없음', () => { + const { result } = renderHook(() => useDebounce(undefined)); + + act(() => { + result.current.handleChange({ + target: { value: 'test' }, + } as ChangeEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current.value).toBe('test'); + }); +}); diff --git a/src/shared/lib/hooks/use-did-mount-effect.test.ts b/src/shared/lib/hooks/use-did-mount-effect.test.ts new file mode 100644 index 00000000..bb478541 --- /dev/null +++ b/src/shared/lib/hooks/use-did-mount-effect.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from '@testing-library/react'; +import useDidMountEffect from './use-did-mount-effect'; + +describe('useDidMountEffect', () => { + test('마운트 완료 후 effect 실행', () => { + const effect = vi.fn(); + + renderHook(() => useDidMountEffect(effect)); + + expect(effect).toHaveBeenCalledTimes(1); + }); + + test('cleanup 함수 호출 확인', () => { + const cleanup = vi.fn(); + const effect = vi.fn(() => cleanup); + + const { unmount } = renderHook(() => useDidMountEffect(effect)); + + unmount(); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + test('effect가 한 번만 실행됨', () => { + const effect = vi.fn(); + + const { rerender } = renderHook(() => useDidMountEffect(effect)); + + rerender(); + rerender(); + + expect(effect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/hooks/use-did-update-effect.test.ts b/src/shared/lib/hooks/use-did-update-effect.test.ts new file mode 100644 index 00000000..20576459 --- /dev/null +++ b/src/shared/lib/hooks/use-did-update-effect.test.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import useDidUpdateEffect from './use-did-update-effect'; + +describe('useDidUpdateEffect', () => { + test('mount 시 effect 실행 안 됨', () => { + const effect = vi.fn(); + + renderHook(({ dep }) => useDidUpdateEffect(effect, [dep]), { + initialProps: { dep: 1 }, + }); + + expect(effect).not.toHaveBeenCalled(); + }); + + test('dependency 변경 시 effect 실행', () => { + const effect = vi.fn(); + + const { rerender } = renderHook(({ dep }) => useDidUpdateEffect(effect, [dep]), { + initialProps: { dep: 1 }, + }); + + rerender({ dep: 2 }); + + expect(effect).toHaveBeenCalledTimes(1); + }); + + test('dependency 미변경 시 effect 실행 안 됨', () => { + const effect = vi.fn(); + + const { rerender } = renderHook(({ dep }) => useDidUpdateEffect(effect, [dep]), { + initialProps: { dep: 1 }, + }); + + rerender({ dep: 1 }); + + expect(effect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/lib/hooks/use-disclosure.hook.test.ts b/src/shared/lib/hooks/use-disclosure.hook.test.ts new file mode 100644 index 00000000..c0265d15 --- /dev/null +++ b/src/shared/lib/hooks/use-disclosure.hook.test.ts @@ -0,0 +1,89 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDisclosure } from './use-disclosure.hook'; + +describe('useDisclosure', () => { + test('기본 상태: open=false', () => { + const { result } = renderHook(() => useDisclosure()); + + expect(result.current.open).toBe(false); + }); + + test('defaultOpen: true → 초기 open=true', () => { + const { result } = renderHook(() => useDisclosure({ defaultOpen: true })); + + expect(result.current.open).toBe(true); + }); + + test('onOpen() 호출 → open=true', () => { + const { result } = renderHook(() => useDisclosure()); + + act(() => { + result.current.onOpen(); + }); + + expect(result.current.open).toBe(true); + }); + + test('onClose() 호출 → open=false', () => { + const { result } = renderHook(() => useDisclosure({ defaultOpen: true })); + + act(() => { + result.current.onClose(); + }); + + expect(result.current.open).toBe(false); + }); + + test('onToggle() 호출 → 상태 반전', () => { + const { result } = renderHook(() => useDisclosure()); + + expect(result.current.open).toBe(false); + + act(() => { + result.current.onToggle(); + }); + expect(result.current.open).toBe(true); + + act(() => { + result.current.onToggle(); + }); + expect(result.current.open).toBe(false); + }); + + test('제어 모드: open prop이 내부 상태를 오버라이드', () => { + const { result } = renderHook(() => useDisclosure({ open: true })); + + expect(result.current.open).toBe(true); + + act(() => { + result.current.onClose(); + }); + + // 제어 모드이므로 내부 상태 변경 없이 open prop 값 유지 + expect(result.current.open).toBe(true); + }); + + test('onOpen 콜백 함수 호출 확인', () => { + const onOpenCallback = vi.fn(); + const { result } = renderHook(() => useDisclosure({ onOpen: onOpenCallback })); + + act(() => { + result.current.onOpen(); + }); + + expect(onOpenCallback).toHaveBeenCalledTimes(1); + }); + + test('onClose 콜백 함수 호출 확인', () => { + const onCloseCallback = vi.fn(); + const { result } = renderHook(() => + useDisclosure({ defaultOpen: true, onClose: onCloseCallback }) + ); + + act(() => { + result.current.onClose(); + }); + + expect(onCloseCallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/hooks/use-intersection-observer.hook.test.ts b/src/shared/lib/hooks/use-intersection-observer.hook.test.ts new file mode 100644 index 00000000..ee823933 --- /dev/null +++ b/src/shared/lib/hooks/use-intersection-observer.hook.test.ts @@ -0,0 +1,79 @@ +import { renderHook, act } from '@testing-library/react'; +import useIntersectionObserver from './use-intersection-observer.hook'; + +vi.mock('@/shared/lib/functions/repeat-animation-frame', () => ({ + repeatAnimationFrame: (fn: () => void) => { + fn(); + return () => {}; + }, +})); + +let observerCallback: IntersectionObserverCallback; +const mockObserve = vi.fn(); +const mockUnobserve = vi.fn(); +const mockDisconnect = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + + vi.stubGlobal( + 'IntersectionObserver', + vi.fn(function (this: any, cb: IntersectionObserverCallback) { + observerCallback = cb; + this.observe = mockObserve; + this.unobserve = mockUnobserve; + this.disconnect = mockDisconnect; + this.takeRecords = vi.fn(); + this.root = null; + this.rootMargin = ''; + this.thresholds = []; + }) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useIntersectionObserver', () => { + test('초기 상태에서 isIntersecting은 false이다', () => { + const { result } = renderHook(() => useIntersectionObserver()); + expect(result.current.isIntersecting).toBe(false); + }); + + test('ref가 설정되면 observe가 호출된다', () => { + const { result } = renderHook(() => useIntersectionObserver()); + const el = document.createElement('div'); + + act(() => { + result.current.setRef(el); + }); + + expect(mockObserve).toHaveBeenCalledWith(el); + }); + + test('IntersectionObserver 콜백으로 isIntersecting이 업데이트된다', () => { + const { result } = renderHook(() => useIntersectionObserver()); + const el = document.createElement('div'); + + act(() => { + result.current.setRef(el); + }); + + act(() => { + observerCallback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + expect(result.current.isIntersecting).toBe(true); + }); + + test('options이 IntersectionObserver에 전달된다', () => { + const options = { threshold: 0.5, rootMargin: '10px' }; + renderHook(() => useIntersectionObserver(options)); + + expect(global.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), options); + }); +}); diff --git a/src/shared/lib/hooks/use-isomorphic-layout-effect.hook.test.ts b/src/shared/lib/hooks/use-isomorphic-layout-effect.hook.test.ts new file mode 100644 index 00000000..f119bcc3 --- /dev/null +++ b/src/shared/lib/hooks/use-isomorphic-layout-effect.hook.test.ts @@ -0,0 +1,9 @@ +import { useLayoutEffect } from 'react'; +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect.hook'; + +describe('useIsomorphicLayoutEffect', () => { + test('브라우저 환경(window 존재) → useLayoutEffect를 export한다', () => { + // jsdom 환경에서는 window가 존재하므로 useLayoutEffect가 선택됨 + expect(useIsomorphicLayoutEffect).toBe(useLayoutEffect); + }); +}); diff --git a/src/shared/lib/hooks/use-portal-root.hook.test.ts b/src/shared/lib/hooks/use-portal-root.hook.test.ts new file mode 100644 index 00000000..57658003 --- /dev/null +++ b/src/shared/lib/hooks/use-portal-root.hook.test.ts @@ -0,0 +1,30 @@ +import { renderHook } from '@testing-library/react'; +import usePortalRoot from './use-portal-root.hook'; + +vi.mock('@/shared/lib/functions/log/logger', () => ({ + errorLog: vi.fn(), +})); + +vi.mock('@/shared/lib/functions/log/with-debugger', () => ({ + __esModule: true, + default: () => (fn: any) => fn, +})); + +describe('usePortalRoot', () => { + test('DOM에 DrawerRoot 요소가 있으면 해당 요소를 반환한다', () => { + const root = document.createElement('div'); + root.id = 'drawer-root'; + document.body.appendChild(root); + + const { result } = renderHook(() => usePortalRoot('drawer-root')); + + expect(result.current).toBe(root); + + document.body.removeChild(root); + }); + + test('DOM에 DrawerRoot 요소가 없으면 null을 반환한다', () => { + const { result } = renderHook(() => usePortalRoot('nonexistent')); + expect(result.current).toBeNull(); + }); +}); diff --git a/src/shared/lib/hooks/use-vertical-stretch.hook.test.ts b/src/shared/lib/hooks/use-vertical-stretch.hook.test.ts new file mode 100644 index 00000000..3dfd3412 --- /dev/null +++ b/src/shared/lib/hooks/use-vertical-stretch.hook.test.ts @@ -0,0 +1,69 @@ +import { renderHook, act } from '@testing-library/react'; +import { useVerticalStretch } from './use-vertical-stretch.hook'; + +describe('useVerticalStretch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('부모가 flex column이면 flex: 1이 설정된다', () => { + const parent = document.createElement('div'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + + // jsdom의 getComputedStyle은 인라인 스타일을 정확히 반영하지 않으므로 직접 mock + vi.stubGlobal( + 'getComputedStyle', + vi.fn().mockReturnValue({ + display: 'flex', + flexDirection: 'column', + }) + ); + + const { result } = renderHook(() => useVerticalStretch()); + + act(() => { + result.current(child); + }); + + expect(child.style.flexGrow).toBe('1'); + + document.body.removeChild(parent); + }); + + test('부모가 flex column이 아니면 height: 100%가 설정된다', () => { + const parent = document.createElement('div'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + + vi.stubGlobal( + 'getComputedStyle', + vi.fn().mockReturnValue({ + display: 'block', + flexDirection: '', + }) + ); + + const { result } = renderHook(() => useVerticalStretch()); + + act(() => { + result.current(child); + }); + + expect(child.style.height).toBe('100%'); + + document.body.removeChild(parent); + }); + + test('ref가 null이면 아무 동작도 하지 않는다', () => { + const { result } = renderHook(() => useVerticalStretch()); + + act(() => { + result.current(null); + }); + + // No error thrown + }); +}); diff --git a/src/shared/lib/localization/renderer/index.ts b/src/shared/lib/localization/renderer/index.ts index a3954eaa..6cc17fe4 100644 --- a/src/shared/lib/localization/renderer/index.ts +++ b/src/shared/lib/localization/renderer/index.ts @@ -1,5 +1,3 @@ -export { default as Trans } from './trans.component'; - export { default as LineBreakProcessor } from './processors/line-break-processor'; export { default as BoldProcessor } from './processors/bold-processor'; export { default as VariableProcessor } from './processors/variable-processor'; diff --git a/src/shared/lib/localization/renderer/index.ui.ts b/src/shared/lib/localization/renderer/index.ui.ts new file mode 100644 index 00000000..ed5a6ed8 --- /dev/null +++ b/src/shared/lib/localization/renderer/index.ui.ts @@ -0,0 +1 @@ +export { default as Trans } from './trans.component'; diff --git a/src/shared/lib/localization/renderer/processors/bold-processor.test.ts b/src/shared/lib/localization/renderer/processors/bold-processor.test.ts new file mode 100644 index 00000000..6943ac8b --- /dev/null +++ b/src/shared/lib/localization/renderer/processors/bold-processor.test.ts @@ -0,0 +1,25 @@ +import BoldProcessor from './bold-processor'; + +describe('BoldProcessor', () => { + test('**text**를 태그로 변환', () => { + const processor = new BoldProcessor(); + expect(processor.process('**굵게**')).toBe('굵게'); + }); + + test('여러 볼드 텍스트 변환', () => { + const processor = new BoldProcessor(); + expect(processor.process('**A**와 **B**')).toBe( + 'AB' + ); + }); + + test('볼드 마크업 없으면 그대로 반환', () => { + const processor = new BoldProcessor(); + expect(processor.process('일반 텍스트')).toBe('일반 텍스트'); + }); + + test('빈 문자열', () => { + const processor = new BoldProcessor(); + expect(processor.process('')).toBe(''); + }); +}); diff --git a/src/shared/lib/localization/renderer/processors/bold-processor.ts b/src/shared/lib/localization/renderer/processors/bold-processor.ts index 3773febb..bbab7f5e 100644 --- a/src/shared/lib/localization/renderer/processors/bold-processor.ts +++ b/src/shared/lib/localization/renderer/processors/bold-processor.ts @@ -4,6 +4,6 @@ import type { I18nProcessor } from './_interface'; @Singleton export default class BoldProcessor implements I18nProcessor { public process(t: string): string { - return t.replace(/\*\*(.*?)\*\*/g, '$1'); + return t.replace(/\*\*(.*?)\*\*/g, '$1'); } } diff --git a/src/shared/lib/localization/renderer/processors/line-break-processor.test.ts b/src/shared/lib/localization/renderer/processors/line-break-processor.test.ts new file mode 100644 index 00000000..9ab4a480 --- /dev/null +++ b/src/shared/lib/localization/renderer/processors/line-break-processor.test.ts @@ -0,0 +1,23 @@ +import LineBreakProcessor from './line-break-processor'; + +describe('LineBreakProcessor', () => { + test('줄바꿈을
태그로 변환', () => { + const processor = new LineBreakProcessor(); + expect(processor.process('첫째 줄\n둘째 줄')).toBe('첫째 줄
둘째 줄'); + }); + + test('여러 줄바꿈 변환', () => { + const processor = new LineBreakProcessor(); + expect(processor.process('A\nB\nC')).toBe('A
B
C'); + }); + + test('줄바꿈 없으면 그대로 반환', () => { + const processor = new LineBreakProcessor(); + expect(processor.process('한 줄 텍스트')).toBe('한 줄 텍스트'); + }); + + test('빈 문자열', () => { + const processor = new LineBreakProcessor(); + expect(processor.process('')).toBe(''); + }); +}); diff --git a/src/shared/lib/localization/renderer/processors/variable-processor-util.test.ts b/src/shared/lib/localization/renderer/processors/variable-processor-util.test.ts new file mode 100644 index 00000000..76458beb --- /dev/null +++ b/src/shared/lib/localization/renderer/processors/variable-processor-util.test.ts @@ -0,0 +1,27 @@ +import { processI18nString } from './variable-processor-util'; + +describe('processI18nString', () => { + test('단일 변수를 치환한다', () => { + expect(processI18nString('Hello, {{name}}!', { name: 'World' })).toBe('Hello, World!'); + }); + + test('복수 변수를 치환한다', () => { + expect(processI18nString('{{greeting}}, {{name}}!', { greeting: 'Hi', name: 'Alice' })).toBe( + 'Hi, Alice!' + ); + }); + + test('누락된 키는 빈 문자열로 치환한다', () => { + expect(processI18nString('Hello, {{name}}!', {})).toBe('Hello, !'); + }); + + test('플레이스홀더가 없는 문자열은 그대로 반환한다', () => { + expect(processI18nString('No placeholders here', { name: 'World' })).toBe( + 'No placeholders here' + ); + }); + + test('빈 문자열을 입력하면 빈 문자열을 반환한다', () => { + expect(processI18nString('', { name: 'World' })).toBe(''); + }); +}); diff --git a/src/shared/lib/localization/renderer/processors/variable-processor.test.ts b/src/shared/lib/localization/renderer/processors/variable-processor.test.ts new file mode 100644 index 00000000..5347084c --- /dev/null +++ b/src/shared/lib/localization/renderer/processors/variable-processor.test.ts @@ -0,0 +1,41 @@ +import VariableProcessor from './variable-processor'; +import { processI18nString } from './variable-processor-util'; + +describe('VariableProcessor', () => { + test('변수 치환', () => { + const processor = new VariableProcessor({ name: 'Alice' }); + expect(processor.process('Hello {{name}}')).toBe('Hello Alice'); + }); + + test('여러 변수 동시 치환', () => { + const processor = new VariableProcessor({ a: '1', b: '2' }); + expect(processor.process('{{a}} + {{b}}')).toBe('1 + 2'); + }); + + test('존재하지 않는 변수는 빈 문자열로 치환', () => { + const processor = new VariableProcessor({}); + expect(processor.process('Hello {{name}}')).toBe('Hello '); + }); + + test('변수가 없는 문자열은 그대로 반환', () => { + const processor = new VariableProcessor({ name: 'Alice' }); + expect(processor.process('Hello World')).toBe('Hello World'); + }); + + test('같은 변수 여러 번 사용', () => { + const processor = new VariableProcessor({ x: 'Y' }); + expect(processor.process('{{x}} and {{x}}')).toBe('Y and Y'); + }); +}); + +describe('processI18nString', () => { + test('변수 치환 유틸 함수', () => { + expect(processI18nString('{{points}} 포인트 필요', { points: '50 DJ' })).toBe( + '50 DJ 포인트 필요' + ); + }); + + test('빈 변수 객체', () => { + expect(processI18nString('텍스트', {})).toBe('텍스트'); + }); +}); diff --git a/src/shared/lib/localization/use-change-language.hook.test.ts b/src/shared/lib/localization/use-change-language.hook.test.ts new file mode 100644 index 00000000..1b910e08 --- /dev/null +++ b/src/shared/lib/localization/use-change-language.hook.test.ts @@ -0,0 +1,34 @@ +import { renderHook, act } from '@testing-library/react'; +import { useChangeLanguage } from './use-change-language.hook'; + +const mockRefresh = vi.fn(); +vi.mock('@/shared/lib/router/use-app-router.hook', () => ({ + useAppRouter: () => ({ refresh: mockRefresh }), +})); + +const mockSetCookie = vi.fn(); +vi.mock('cookies-next', () => ({ + setCookie: (...args: any[]) => mockSetCookie(...args), +})); + +vi.mock('@/shared/lib/localization/constants', () => ({ + Language: { En: 'en', Ko: 'ko' }, + LANGUAGE_COOKIE_KEY: 'lang', +})); + +describe('useChangeLanguage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('언어 변경 시 쿠키가 설정되고 라우터가 새로고침된다', () => { + const { result } = renderHook(() => useChangeLanguage()); + + act(() => { + result.current('ko' as any); + }); + + expect(mockSetCookie).toHaveBeenCalledWith('lang', 'ko'); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/localization/use-languages.hook.test.ts b/src/shared/lib/localization/use-languages.hook.test.ts new file mode 100644 index 00000000..841331d2 --- /dev/null +++ b/src/shared/lib/localization/use-languages.hook.test.ts @@ -0,0 +1,38 @@ +vi.mock('@/shared/lib/localization/lang.context', () => ({ + useLang: vi.fn(), +})); +vi.mock('@/shared/lib/localization/i18n.context', () => ({ + useI18n: () => ({ common: { btn: { eng: 'English', kor: '한국어' } } }), +})); + +import { renderHook } from '@testing-library/react'; +import { useLang } from '@/shared/lib/localization/lang.context'; +import useLanguages from './use-languages.hook'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useLanguages', () => { + test('영어와 한국어 옵션을 반환한다', () => { + (useLang as Mock).mockReturnValue('en'); + const { result } = renderHook(() => useLanguages()); + expect(result.current).toHaveLength(2); + expect(result.current[0].label).toBe('English'); + expect(result.current[1].label).toBe('한국어'); + }); + + test('현재 언어가 en이면 영어의 isCurrent가 true이다', () => { + (useLang as Mock).mockReturnValue('en'); + const { result } = renderHook(() => useLanguages()); + expect(result.current[0].isCurrent).toBe(true); + expect(result.current[1].isCurrent).toBe(false); + }); + + test('현재 언어가 ko이면 한국어의 isCurrent가 true이다', () => { + (useLang as Mock).mockReturnValue('ko'); + const { result } = renderHook(() => useLanguages()); + expect(result.current[0].isCurrent).toBe(false); + expect(result.current[1].isCurrent).toBe(true); + }); +}); diff --git a/src/shared/lib/router/parse-href.test.ts b/src/shared/lib/router/parse-href.test.ts new file mode 100644 index 00000000..ac1b6a0c --- /dev/null +++ b/src/shared/lib/router/parse-href.test.ts @@ -0,0 +1,42 @@ +import { parseHref } from './parse-href'; + +describe('parseHref', () => { + test('파라미터 없이 href 그대로 반환', () => { + expect(parseHref('/parties')).toBe('/parties'); + }); + + test('path 변수 치환', () => { + expect(parseHref('/parties/[id]', { path: { id: 123 } })).toBe('/parties/123'); + }); + + test('여러 path 변수 치환', () => { + expect( + parseHref('/parties/[id]/members/[memberId]', { + path: { id: 1, memberId: 42 }, + }) + ).toBe('/parties/1/members/42'); + }); + + test('존재하지 않는 path 변수는 원본 유지', () => { + expect(parseHref('/parties/[id]', { path: {} })).toBe('/parties/[id]'); + }); + + test('query 파라미터 추가', () => { + const result = parseHref('/parties', { query: { page: 1, sort: 'latest' } }); + expect(result).toContain('/parties?'); + expect(result).toContain('page=1'); + expect(result).toContain('sort=latest'); + }); + + test('path와 query 동시 사용', () => { + const result = parseHref('/parties/[id]', { + path: { id: 5 }, + query: { tab: 'info' }, + }); + expect(result).toBe('/parties/5?tab=info'); + }); + + test('문자열 path 변수 치환', () => { + expect(parseHref('/rooms/[slug]', { path: { slug: 'my-room' } })).toBe('/rooms/my-room'); + }); +}); diff --git a/src/shared/lib/router/use-app-router.hook.test.ts b/src/shared/lib/router/use-app-router.hook.test.ts new file mode 100644 index 00000000..7192ee45 --- /dev/null +++ b/src/shared/lib/router/use-app-router.hook.test.ts @@ -0,0 +1,68 @@ +import { renderHook, act } from '@testing-library/react'; +import { useAppRouter } from './use-app-router.hook'; + +const mockPush = vi.fn(); +const mockReplace = vi.fn(); +const mockPrefetch = vi.fn(); +const mockRefresh = vi.fn(); +const mockBack = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + prefetch: mockPrefetch, + refresh: mockRefresh, + back: mockBack, + }), +})); + +vi.mock('./parse-href', () => ({ + parseHref: (href: string, options: any) => { + if (options?.path) { + let result = href as string; + for (const [key, value] of Object.entries(options.path)) { + result = result.replace(`[${key}]`, String(value)); + } + return result; + } + if (options?.query) { + const qs = new URLSearchParams(options.query).toString(); + return `${href}?${qs}`; + } + return href; + }, +})); + +describe('useAppRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('push가 parseHref를 통해 변환된 경로로 호출된다', () => { + const { result } = renderHook(() => useAppRouter()); + + act(() => { + result.current.push('/' as any); + }); + + expect(mockPush).toHaveBeenCalledWith('/', undefined); + }); + + test('replace가 동작한다', () => { + const { result } = renderHook(() => useAppRouter()); + + act(() => { + result.current.replace('/' as any); + }); + + expect(mockReplace).toHaveBeenCalled(); + }); + + test('refresh, back 등 원본 메서드가 전달된다', () => { + const { result } = renderHook(() => useAppRouter()); + + expect(result.current.refresh).toBe(mockRefresh); + expect(result.current.back).toBe(mockBack); + }); +}); diff --git a/src/shared/ui/components/back-button/back-button.component.test.tsx b/src/shared/ui/components/back-button/back-button.component.test.tsx new file mode 100644 index 00000000..e6d4d8d8 --- /dev/null +++ b/src/shared/ui/components/back-button/back-button.component.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import BackButton from './back-button.component'; + +const mockBack = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ back: mockBack }), +})); + +vi.mock('../text-button', () => ({ + TextButton: ({ children, onClick, Icon }: any) => ( + + ), +})); + +vi.mock('@/shared/ui/icons', () => ({ + PFArrowLeft: (props: any) => , +})); + +describe('BackButton', () => { + beforeEach(() => { + mockBack.mockClear(); + }); + + test('텍스트가 렌더링된다', () => { + render(); + expect(screen.getByText('뒤로가기')).toBeTruthy(); + }); + + test('클릭 시 router.back()이 호출된다', () => { + render(); + fireEvent.click(screen.getByTestId('text-button')); + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + test('화살표 아이콘이 렌더링된다', () => { + render(); + expect(screen.getByTestId('arrow-left')).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/components/button/button.component.test.tsx b/src/shared/ui/components/button/button.component.test.tsx new file mode 100644 index 00000000..95a07a58 --- /dev/null +++ b/src/shared/ui/components/button/button.component.test.tsx @@ -0,0 +1,95 @@ +import { createRef } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Button from './button.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children }: any) => {children}, + TypographyType: {}, +})); + +vi.mock('../loading', () => ({ + Loading: () => , +})); + +describe('Button', () => { + test('children 텍스트가 렌더링된다', () => { + render(); + expect(screen.getByText('확인')).toBeTruthy(); + }); + + test('onClick 콜백이 호출된다', () => { + const onClick = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('disabled 상태에서 클릭해도 onClick이 호출되지 않는다', () => { + const onClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('disabled 상태에서 button 요소에 disabled 속성이 적용된다', () => { + render(); + expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(true); + }); + + test('loading 상태에서 Loading 컴포넌트가 표시된다', () => { + render(); + + expect(screen.getByTestId('loading-spinner')).toBeTruthy(); + }); + + test('loading 상태에서 클릭해도 onClick이 호출되지 않는다', () => { + const onClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('loading 상태에서 button 요소에 disabled 속성이 적용된다', () => { + render(); + expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(true); + }); + + test('Icon이 렌더링된다', () => { + render(); + expect(screen.getByTestId('icon')).toBeTruthy(); + }); + + test('iconPlacement="right"일 때 아이콘이 텍스트 뒤에 위치한다', () => { + const { container } = render( + + ); + + const button = container.querySelector('button') as HTMLElement; + const children = Array.from(button.children); + const textIndex = children.findIndex((c) => c.textContent === '텍스트'); + const iconIndex = children.findIndex((c) => c.getAttribute('data-testid') === 'icon'); + + expect(iconIndex).toBeGreaterThan(textIndex); + }); + + test('ref가 button 요소에 전달된다', () => { + const ref = createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/src/shared/ui/components/checkbox/checkbox.component.test.tsx b/src/shared/ui/components/checkbox/checkbox.component.test.tsx new file mode 100644 index 00000000..fa2f1b63 --- /dev/null +++ b/src/shared/ui/components/checkbox/checkbox.component.test.tsx @@ -0,0 +1,55 @@ +import { createRef } from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Checkbox from './checkbox.component'; + +vi.mock('@/shared/ui/icons', () => ({ + PFCheckMark: (props: any) => , +})); + +describe('Checkbox', () => { + test('클릭으로 체크/해제 토글이 동작한다', () => { + const { container } = render(); + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + const label = container.querySelector('label') as HTMLElement; + + expect(checkbox.checked).toBe(false); + + fireEvent.click(label); + expect(checkbox.checked).toBe(true); + + fireEvent.click(label); + expect(checkbox.checked).toBe(false); + }); + + test('disabled 상태에서는 클릭해도 토글되지 않는다', () => { + const { container } = render(); + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + const label = container.querySelector('label') as HTMLElement; + + expect(checkbox.checked).toBe(false); + + fireEvent.click(label); + expect(checkbox.checked).toBe(false); + }); + + test('onChange 콜백이 호출된다', () => { + const onChange = vi.fn(); + const { container } = render(); + + const label = container.querySelector('label') as HTMLElement; + fireEvent.click(label); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + test('ref가 input 요소에 전달된다', () => { + const ref = createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect((ref.current as HTMLInputElement).type).toBe('checkbox'); + }); +}); diff --git a/src/shared/ui/components/collapse-list/collapse-list.component.test.tsx b/src/shared/ui/components/collapse-list/collapse-list.component.test.tsx new file mode 100644 index 00000000..4cb32a66 --- /dev/null +++ b/src/shared/ui/components/collapse-list/collapse-list.component.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import CollapseList from './collapse-list.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children }: any) => {children}, +})); + +vi.mock('@/shared/ui/icons', () => ({ + PFChevronDown: () => , + PFChevronUp: () => , +})); + +describe('CollapseList', () => { + test('제목이 렌더링된다', () => { + render(내용); + expect(screen.getByText('접을 수 있는 목록')).toBeTruthy(); + }); + + test('초기 상태에서 내용이 숨겨져 있다', () => { + render(숨겨진 내용); + expect(screen.queryByText('숨겨진 내용')).toBeNull(); + }); + + test('버튼 클릭 시 내용이 표시된다', () => { + render(펼쳐진 내용); + + fireEvent.click(screen.getByText('목록')); + expect(screen.getByText('펼쳐진 내용')).toBeTruthy(); + }); + + test('infoText가 렌더링된다', () => { + render( + + 내용 + + ); + expect(screen.getByText('3개')).toBeTruthy(); + }); + + test('displaySuffix=false일 때 chevron 아이콘이 표시되지 않는다', () => { + render( + + 내용 + + ); + expect(screen.queryByTestId('chevron-down')).toBeNull(); + expect(screen.queryByTestId('chevron-up')).toBeNull(); + }); + + test('PrefixIcon이 렌더링된다', () => { + render( + }> + 내용 + + ); + expect(screen.getByTestId('prefix')).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/components/dialog/dialog.component.test.tsx b/src/shared/ui/components/dialog/dialog.component.test.tsx new file mode 100644 index 00000000..39e7d09b --- /dev/null +++ b/src/shared/ui/components/dialog/dialog.component.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Dialog from './dialog.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children }: any) => {children}, + TypographyType: {}, +})); + +vi.mock('../button', () => ({ + Button: ({ children, ...rest }: any) => , + ButtonProps: {}, +})); + +vi.mock('@/shared/ui/components/text-button', () => ({ + TextButton: ({ onClick, Icon }: any) => ( + + ), +})); + +vi.mock('@/shared/ui/icons', () => ({ + PFClose: () => , +})); + +vi.mock('@/shared/ui/foundation/theme', () => ({ + __esModule: true, + default: { zIndex: { dialog: 50 } }, +})); + +global.ResizeObserver = class ResizeObserver { + public observe() { + /* noop */ + } + public unobserve() { + /* noop */ + } + public disconnect() { + /* noop */ + } +} as any; + +describe('Dialog', () => { + const defaultProps = { + open: true, + onClose: vi.fn(), + Body:
바디 내용
, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('open=true일 때 Body가 렌더링된다', () => { + render(); + expect(screen.getByText('바디 내용')).toBeTruthy(); + }); + + test('open=false일 때 Body가 렌더링되지 않는다', () => { + render(); + expect(screen.queryByText('바디 내용')).toBeNull(); + }); + + test('문자열 title이 렌더링된다', () => { + render(); + expect(screen.getByText('다이얼로그 제목')).toBeTruthy(); + }); + + test('함수형 title이 렌더링된다', () => { + render( +

커스텀 제목

} + /> + ); + expect(screen.getByText('커스텀 제목')).toBeTruthy(); + }); + + test('Body가 FC일 때 렌더링된다', () => { + const BodyComponent = () =>
함수형 바디
; + render(); + expect(screen.getByText('함수형 바디')).toBeTruthy(); + }); + + test('showCloseIcon=true일 때 닫기 아이콘이 표시된다', () => { + render(); + expect(screen.getByTestId('close-icon')).toBeTruthy(); + }); + + test('닫기 아이콘 클릭 시 onClose가 호출된다', async () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('close-icon')); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + test('Sub가 렌더링된다', () => { + render(부제목
} />); + expect(screen.getByText('부제목')).toBeTruthy(); + }); +}); + +describe('Dialog.ButtonGroup / Dialog.Button', () => { + test('ButtonGroup이 children을 렌더링한다', () => { + render( + + 버튼들 + + ); + expect(screen.getByText('버튼들')).toBeTruthy(); + }); + + test('Dialog.Button이 children을 렌더링한다', () => { + render(확인); + expect(screen.getByText('확인')).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/components/dialog/dialog.provider.tsx b/src/shared/ui/components/dialog/dialog.provider.tsx index 9eff132f..8eb850c0 100644 --- a/src/shared/ui/components/dialog/dialog.provider.tsx +++ b/src/shared/ui/components/dialog/dialog.provider.tsx @@ -1,5 +1,5 @@ 'use client'; -import { ReactElement, useCallback, useMemo, useState } from 'react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; import { delay } from '@/shared/lib/functions/delay'; import Dialog, { DialogProps } from './dialog.component'; import { DialogContext, type DialogID, type PopDialog, type PushDialog } from './dialog.context'; @@ -9,7 +9,7 @@ type DialogOptions = Omit & { }; type DialogProviderProps = { - children: ReactElement; + children: ReactNode; }; export const DialogProvider = ({ children }: DialogProviderProps) => { diff --git a/src/shared/ui/components/dialog/open.tsx b/src/shared/ui/components/dialog/open.tsx index 8f71407b..1d96a6dc 100644 --- a/src/shared/ui/components/dialog/open.tsx +++ b/src/shared/ui/components/dialog/open.tsx @@ -22,7 +22,7 @@ export default function open(params: DialogStaticOpenParams): { destroy: () => v function destroy() { for (let i = 0; i < destroyFns.length; i++) { const fn = destroyFns[i]; - // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (fn === close) { destroyFns.splice(i, 1); break; diff --git a/src/shared/ui/components/dialog/use-dialog.hook.test.tsx b/src/shared/ui/components/dialog/use-dialog.hook.test.tsx new file mode 100644 index 00000000..de8a3bae --- /dev/null +++ b/src/shared/ui/components/dialog/use-dialog.hook.test.tsx @@ -0,0 +1,92 @@ +const mockOpenDialog = vi.fn(); +const mockCloseDialog = vi.fn(); + +vi.mock('./dialog.context', () => ({ + useDialogContext: () => ({ + openDialog: mockOpenDialog, + closeDialog: mockCloseDialog, + }), +})); +vi.mock('@/shared/lib/localization/i18n.context', () => ({ + useI18n: () => ({ + common: { btn: { confirm: '확인', cancel: '취소' } }, + }), +})); + +import { renderHook, act } from '@testing-library/react'; +import { useDialog } from './use-dialog.hook'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useDialog', () => { + test('openDialog와 closeDialog를 반환한다', () => { + const { result } = renderHook(() => useDialog()); + expect(result.current.openDialog).toBe(mockOpenDialog); + expect(result.current.closeDialog).toBe(mockCloseDialog); + }); + + test('openAlertDialog를 호출하면 openDialog가 factory와 함께 호출된다', async () => { + mockOpenDialog.mockResolvedValue(undefined); + const { result } = renderHook(() => useDialog()); + + await act(async () => { + await result.current.openAlertDialog({ title: '알림', content: '내용' }); + }); + + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + expect(typeof mockOpenDialog.mock.calls[0][0]).toBe('function'); + }); + + test('openConfirmDialog를 호출하면 openDialog가 factory와 함께 호출된다', async () => { + mockOpenDialog.mockResolvedValue(true); + const { result } = renderHook(() => useDialog()); + + await act(async () => { + await result.current.openConfirmDialog({ title: '확인', content: '진행?' }); + }); + + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + expect(typeof mockOpenDialog.mock.calls[0][0]).toBe('function'); + }); + + test('openErrorDialog를 호출하면 openDialog가 호출된다', async () => { + mockOpenDialog.mockResolvedValue(undefined); + const { result } = renderHook(() => useDialog()); + + await act(async () => { + await result.current.openErrorDialog(new Error('테스트 에러')); + }); + + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + }); + + test('openAlertDialog factory는 title과 Body를 포함하는 객체를 반환한다', async () => { + mockOpenDialog.mockImplementation(async (factory: (...args: any[]) => any) => { + const config = factory(vi.fn(), vi.fn()); + expect(config.title).toBe('알림'); + expect(typeof config.Body).toBe('function'); + }); + + const { result } = renderHook(() => useDialog()); + await act(async () => { + await result.current.openAlertDialog({ title: '알림', content: '테스트' }); + }); + }); + + test('openConfirmDialog factory의 Body에서 onOk(true)와 onOk(false)를 호출할 수 있다', async () => { + const onOk = vi.fn(); + mockOpenDialog.mockImplementation(async (factory: (...args: any[]) => any) => { + const config = factory(onOk, vi.fn()); + expect(typeof config.Body).toBe('function'); + }); + + const { result } = renderHook(() => useDialog()); + await act(async () => { + await result.current.openConfirmDialog({ title: '확인', content: '내용' }); + }); + + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/ui/components/dj-list-item/dj-list-item.component.test.tsx b/src/shared/ui/components/dj-list-item/dj-list-item.component.test.tsx new file mode 100644 index 00000000..1e8ec854 --- /dev/null +++ b/src/shared/ui/components/dj-list-item/dj-list-item.component.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import DjListItem from './dj-list-item.component'; + +vi.mock('next/image', () => ({ + __esModule: true, + default: ({ src, alt, ...rest }: any) => {alt}, +})); + +vi.mock('@/shared/ui/components/typography', () => ({ + Typography: ({ children }: any) => {children}, +})); + +vi.mock('@/shared/ui/components/tag', () => ({ + Tag: ({ value }: any) => {value}, +})); + +describe('DjListItem', () => { + const defaultConfig = { + username: 'DJ One', + src: 'https://example.com/dj.png', + }; + + test('username이 렌더링된다', () => { + render(); + expect(screen.getByText('DJ One')).toBeTruthy(); + }); + + test('아바타 이미지가 렌더링된다', () => { + render(); + const img = screen.getByAltText('DJ One'); + expect(img.getAttribute('src')).toBe('https://example.com/dj.png'); + }); + + test('order가 렌더링된다', () => { + render(); + expect(screen.getByText('1')).toBeTruthy(); + }); + + test('suffixTagValue가 있으면 Tag가 렌더링된다', () => { + render(); + expect(screen.getByTestId('tag')).toBeTruthy(); + expect(screen.getByText('NOW')).toBeTruthy(); + }); + + test('variant="accent"일 때 border 클래스가 적용된다', () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain('border-red-300'); + }); + + test('variant="filled"일 때 bg 클래스가 적용된다', () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain('bg-gray-800'); + }); +}); diff --git a/src/shared/ui/components/drawer/drawer.component.test.tsx b/src/shared/ui/components/drawer/drawer.component.test.tsx new file mode 100644 index 00000000..8c4d12e4 --- /dev/null +++ b/src/shared/ui/components/drawer/drawer.component.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import Drawer from './drawer.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children }: any) => {children}, +})); + +vi.mock('@/shared/ui/components/text-button', () => ({ + TextButton: ({ onClick, Icon }: any) => ( + + ), +})); + +vi.mock('@/shared/ui/icons', () => ({ + PFClose: () => , +})); + +vi.mock('@/shared/lib/hooks/use-portal-root.hook', () => ({ + __esModule: true, + default: () => document.body, +})); + +describe('Drawer', () => { + test('isOpen=true일 때 children이 렌더링된다', () => { + render( + +
서랍 내용
+
+ ); + + expect(screen.getByText('서랍 내용')).toBeTruthy(); + }); + + test('isOpen=false일 때 children이 렌더링되지 않는다', () => { + render( + +
서랍 내용
+
+ ); + + expect(screen.queryByText('서랍 내용')).toBeNull(); + }); + + test('title이 렌더링된다', () => { + render( + +
내용
+
+ ); + + expect(screen.getByText('제목입니다')).toBeTruthy(); + }); + + test('close 콜백이 있으면 닫기 버튼이 표시된다', () => { + render( + +
내용
+
+ ); + + expect(screen.getByTestId('close-button')).toBeTruthy(); + }); + + test('닫기 버튼 클릭 시 close가 호출된다', () => { + const close = vi.fn(); + render( + +
내용
+
+ ); + + fireEvent.click(screen.getByTestId('close-button')); + expect(close).toHaveBeenCalledTimes(1); + }); + + test('isOpen=true일 때 body에 scroll-hidden 클래스가 추가된다', () => { + render( + +
내용
+
+ ); + + expect(document.body.classList.contains('scroll-hidden')).toBe(true); + }); + + test('HeaderExtra가 렌더링된다', () => { + render( + 추가 헤더}> +
내용
+
+ ); + + expect(screen.getByText('추가 헤더')).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/components/form-item/form-item.component.test.tsx b/src/shared/ui/components/form-item/form-item.component.test.tsx new file mode 100644 index 00000000..9af74d50 --- /dev/null +++ b/src/shared/ui/components/form-item/form-item.component.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import FormItem, { FormItemError } from './form-item.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children, className, ...rest }: any) => ( + + {children} + + ), +})); + +describe('FormItem', () => { + test('문자열 label이 렌더링된다', () => { + render( + + + + ); + + expect(screen.getByText('이름')).toBeTruthy(); + }); + + test('ReactNode label이 렌더링된다', () => { + render( + 커스텀 라벨}> + + + ); + + expect(screen.getByText('커스텀 라벨')).toBeTruthy(); + }); + + test('children이 렌더링된다', () => { + render( + + + + ); + + expect(screen.getByPlaceholderText('입력하세요')).toBeTruthy(); + }); + + test('error 문자열이 표시된다', () => { + render( + + + + ); + + expect(screen.getByText('필수 항목입니다')).toBeTruthy(); + }); + + test('error가 boolean true일 때 에러 메시지는 표시되지 않지만 에러 스타일은 적용된다', () => { + const { container } = render( + + + + ); + + const childWrapper = container.querySelector('.outline-red-300'); + expect(childWrapper).not.toBeNull(); + }); + + test('error가 없으면 에러 영역이 렌더링되지 않는다', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('.outline-red-300')).toBeNull(); + }); + + test('required일 때 label에 필수 마커 클래스가 적용된다', () => { + const { container } = render( + + + + ); + + const labelEl = container.querySelector('[data-custom-role="form-item-title"]'); + expect(labelEl).not.toBeNull(); + expect(labelEl?.className).toContain('after:content-["*"]'); + }); +}); + +describe('FormItemError', () => { + test('에러 메시지를 렌더링한다', () => { + render(오류가 발생했습니다); + expect(screen.getByText('오류가 발생했습니다')).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/components/icon-menu/icon-menu.component.test.tsx b/src/shared/ui/components/icon-menu/icon-menu.component.test.tsx new file mode 100644 index 00000000..9564509e --- /dev/null +++ b/src/shared/ui/components/icon-menu/icon-menu.component.test.tsx @@ -0,0 +1,64 @@ +global.ResizeObserver = class ResizeObserver { + public observe() { + /* noop */ + } + public unobserve() { + /* noop */ + } + public disconnect() { + /* noop */ + } +} as any; + +import { render, fireEvent } from '@testing-library/react'; +import IconMenu from './icon-menu.component'; +import type { MenuItem } from '../menu'; + +const createConfig = (...labels: string[]): MenuItem[] => + labels.map((label) => ({ label, onClickItem: vi.fn() })); + +describe('IconMenu', () => { + test('MenuButtonIcon을 렌더링한다', () => { + const { getByText } = render( + 아이콘} menuItemConfig={createConfig('항목1')} /> + ); + expect(getByText('아이콘')).toBeTruthy(); + }); + + test('메뉴 버튼 클릭 시 onMenuIconClick을 호출한다', () => { + const onMenuIconClick = vi.fn(); + const { getByRole } = render( + 아이콘} + menuItemConfig={createConfig('항목1')} + onMenuIconClick={onMenuIconClick} + /> + ); + fireEvent.click(getByRole('button')); + expect(onMenuIconClick).toHaveBeenCalledTimes(1); + }); + + test('menuContainerClassName이 적용된다', () => { + const { container } = render( + 아이콘} + menuItemConfig={createConfig('항목1')} + menuContainerClassName='custom-class' + /> + ); + expect(container.firstElementChild?.className).toContain('custom-class'); + }); + + test('ref를 전달할 수 있다', () => { + const ref = { current: null as HTMLDivElement | null }; + render( + 아이콘} + menuItemConfig={createConfig('항목1')} + /> + ); + expect(ref.current).toBeTruthy(); + expect(ref.current?.tagName).toBe('DIV'); + }); +}); diff --git a/src/shared/ui/components/infinite-scroll/infinite-scroll.component.test.tsx b/src/shared/ui/components/infinite-scroll/infinite-scroll.component.test.tsx new file mode 100644 index 00000000..7aa7a044 --- /dev/null +++ b/src/shared/ui/components/infinite-scroll/infinite-scroll.component.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/react'; +import InfiniteScroll from './infinite-scroll.component'; + +vi.mock('../loading', () => ({ + Loading: () => , +})); + +let mockIsIntersecting = false; +vi.mock('@/shared/lib/hooks/use-intersection-observer.hook', () => ({ + __esModule: true, + default: () => ({ + setRef: vi.fn(), + isIntersecting: mockIsIntersecting, + }), +})); + +describe('InfiniteScroll', () => { + beforeEach(() => { + mockIsIntersecting = false; + }); + + test('children이 렌더링된다', () => { + render( + +
아이템 목록
+
+ ); + + expect(screen.getByText('아이템 목록')).toBeTruthy(); + }); + + test('hasMore=true일 때 Loading이 표시된다', () => { + render( + +
목록
+
+ ); + + expect(screen.getByTestId('loading')).toBeTruthy(); + }); + + test('hasMore=false일 때 endMessage가 표시된다', () => { + render( + +
목록
+
+ ); + + expect(screen.getByText('더 이상 없습니다')).toBeTruthy(); + }); + + test('hasMore=false + endMessage 미지정 시 기본 메시지가 표시된다', () => { + render( + +
목록
+
+ ); + + expect(screen.getByText('No more data')).toBeTruthy(); + }); + + test('isIntersecting + hasMore일 때 loadMore가 호출된다', () => { + mockIsIntersecting = true; + const loadMore = vi.fn(); + + render( + +
목록
+
+ ); + + expect(loadMore).toHaveBeenCalled(); + }); + + test('isIntersecting + hasMore=false일 때 loadMore가 호출되지 않는다', () => { + mockIsIntersecting = true; + const loadMore = vi.fn(); + + render( + +
목록
+
+ ); + + expect(loadMore).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/ui/components/input-number/input-number.component.test.tsx b/src/shared/ui/components/input-number/input-number.component.test.tsx new file mode 100644 index 00000000..1112f381 --- /dev/null +++ b/src/shared/ui/components/input-number/input-number.component.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import InputNumber from './input-number.component'; + +vi.mock('@/shared/lib/functions/combine-ref', () => ({ + combineRef: (refs: any[]) => (el: any) => { + refs.forEach((ref) => { + if (typeof ref === 'function') ref(el); + else if (ref && typeof ref === 'object') ref.current = el; + }); + }, +})); + +describe('InputNumber', () => { + test('숫자만 입력 가능 — 문자가 포함된 값은 숫자만 추출된다', () => { + render(); + + const input = screen.getByPlaceholderText('숫자') as HTMLInputElement; + fireEvent.change(input, { target: { value: '12abc3' } }); + + expect(input.value).toBe('123'); + }); + + test('min 값 미만 입력 시 min으로 보정된다', () => { + render(); + + const input = screen.getByPlaceholderText('숫자') as HTMLInputElement; + fireEvent.change(input, { target: { value: '5' } }); + + expect(input.value).toBe('10'); + }); + + test('max 값 초과 입력 시 max로 보정된다', () => { + render(); + + const input = screen.getByPlaceholderText('숫자') as HTMLInputElement; + fireEvent.change(input, { target: { value: '200' } }); + + expect(input.value).toBe('100'); + }); + + test('비제어 모드: defaultValue 설정 후 타이핑하면 value가 변경된다', () => { + render(); + + const input = screen.getByPlaceholderText('숫자') as HTMLInputElement; + expect(input.value).toBe('42'); + + fireEvent.change(input, { target: { value: '99' } }); + expect(input.value).toBe('99'); + }); + + test('onChange 콜백이 호출된다', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('숫자'); + fireEvent.change(input, { target: { value: '7' } }); + + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/ui/components/input/input.component.test.tsx b/src/shared/ui/components/input/input.component.test.tsx new file mode 100644 index 00000000..40134294 --- /dev/null +++ b/src/shared/ui/components/input/input.component.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import Input from './input.component'; + +vi.mock('../typography', () => ({ + Typography: ({ children, className }: any) => {children}, +})); + +vi.mock('@/shared/lib/functions/combine-ref', () => ({ + combineRef: (refs: any[]) => (el: any) => { + refs.forEach((ref) => { + if (typeof ref === 'function') ref(el); + else if (ref && typeof ref === 'object') ref.current = el; + }); + }, +})); + +describe('Input', () => { + test('기본 렌더링 — input 요소가 존재한다', () => { + render(); + + expect(screen.getByPlaceholderText('테스트')).toBeTruthy(); + }); + + test('비제어 모드: defaultValue 설정 후 타이핑하면 value가 변경된다', () => { + render(); + + const input = screen.getByPlaceholderText('입력') as HTMLInputElement; + expect(input.value).toBe('초기값'); + + fireEvent.change(input, { target: { value: '새로운 값' } }); + expect(input.value).toBe('새로운 값'); + }); + + test('제어 모드: value prop이 반영된다', () => { + render(); + + const input = screen.getByPlaceholderText('입력') as HTMLInputElement; + expect(input.value).toBe('제어값'); + }); + + test('maxLength 카운터가 0/10 형식으로 표시된다', () => { + const { container } = render(); + + expect(container.textContent).toContain('/10'); + expect(container.textContent).toContain('00'); + }); + + test('maxLength 초과 시 빨간색 카운터 클래스가 적용된다', () => { + const { container } = render(); + + const strong = container.querySelector('strong'); + expect(strong).not.toBeNull(); + expect((strong as HTMLElement).className).toContain('text-red-300'); + }); + + test('Enter 키를 누르면 onPressEnter 콜백이 호출된다', () => { + const onPressEnter = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('입력'); + fireEvent.keyDown(input, { key: 'Enter', nativeEvent: { isComposing: false } }); + + expect(onPressEnter).toHaveBeenCalledTimes(1); + }); + + test('Prefix와 Suffix가 렌더링된다', () => { + render(접두사
} Suffix={접미사} placeholder='입력' />); + + expect(screen.getByText('접두사')).toBeTruthy(); + expect(screen.getByText('접미사')).toBeTruthy(); + }); + + test('wrapper 클릭 시 input이 포커스된다', () => { + const { container } = render(); + + const input = screen.getByPlaceholderText('입력'); + const wrapper = container.firstElementChild as HTMLElement; + + const focusSpy = vi.spyOn(input, 'focus'); + fireEvent.click(wrapper); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); + }); +}); diff --git a/src/shared/ui/components/loading/loading.component.test.tsx b/src/shared/ui/components/loading/loading.component.test.tsx new file mode 100644 index 00000000..a57d801c --- /dev/null +++ b/src/shared/ui/components/loading/loading.component.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import Loading from './loading.component'; + +describe('Loading', () => { + test('기본 렌더링 — SVG가 표시된다', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + test('기본 size는 1em이다', () => { + const { container } = render(); + const svg = container.querySelector('svg') as SVGElement; + expect(svg.getAttribute('width')).toBe('1em'); + expect(svg.getAttribute('height')).toBe('1em'); + }); + + test('size prop이 반영된다', () => { + const { container } = render(); + const svg = container.querySelector('svg') as SVGElement; + expect(svg.getAttribute('width')).toBe('32'); + expect(svg.getAttribute('height')).toBe('32'); + }); + + test('color prop이 stroke에 반영된다', () => { + const { container } = render(); + const path = container.querySelector('path'); + expect(path?.getAttribute('stroke')).toBe('#ff0000'); + }); + + test('animate-loading 클래스가 적용된다', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg?.className.baseVal).toContain('animate-loading'); + }); +}); diff --git a/src/shared/ui/components/menu/menu-button.component.test.tsx b/src/shared/ui/components/menu/menu-button.component.test.tsx new file mode 100644 index 00000000..c9b0bfa6 --- /dev/null +++ b/src/shared/ui/components/menu/menu-button.component.test.tsx @@ -0,0 +1,45 @@ +global.ResizeObserver = class ResizeObserver { + public observe() { + /* noop */ + } + public unobserve() { + /* noop */ + } + public disconnect() { + /* noop */ + } +} as any; + +import { Menu } from '@headlessui/react'; +import { render, fireEvent } from '@testing-library/react'; +import MenuButton from './menu-button.component'; + +const renderInMenu = (ui: React.ReactElement) => render({ui}); + +describe('MenuButton', () => { + test('children을 렌더링한다', () => { + const { getByText } = renderInMenu(메뉴); + expect(getByText('메뉴')).toBeTruthy(); + }); + + test('클릭 시 onMenuIconClick을 호출한다', () => { + const onClick = vi.fn(); + const { getByRole } = renderInMenu( + + 아이콘 + + ); + fireEvent.click(getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('type="button"이면 border 스타일 클래스를 포함한다', () => { + const { getByRole } = renderInMenu(버튼); + expect(getByRole('button').className).toContain('border'); + }); + + test('type="icon"이면 text-gray-50 클래스를 포함한다', () => { + const { getByRole } = renderInMenu(아이콘); + expect(getByRole('button').className).toContain('text-gray-50'); + }); +}); diff --git a/src/shared/ui/components/menu/menu-item-panel.component.test.tsx b/src/shared/ui/components/menu/menu-item-panel.component.test.tsx new file mode 100644 index 00000000..db4040ab --- /dev/null +++ b/src/shared/ui/components/menu/menu-item-panel.component.test.tsx @@ -0,0 +1,88 @@ +global.ResizeObserver = class ResizeObserver { + public observe() { + /* noop */ + } + public unobserve() { + /* noop */ + } + public disconnect() { + /* noop */ + } +} as any; + +vi.mock('@headlessui/react', () => { + return { + Transition: ({ children }: any) => children, + MenuItems: ({ children, as: As = 'div', ...props }: any) => { + const { anchor: _anchor, ...rest } = props; + return As === 'ul' ?
    {children}
:
{children}
; + }, + MenuItem: ({ children }: any) => { + const rendered = typeof children === 'function' ? children({ focus: false }) : children; + return <>{rendered}; + }, + }; +}); + +import { render, fireEvent } from '@testing-library/react'; +import MenuItemPanel from './menu-item-panel.component'; +import type { MenuItem } from './menu-item-panel.component'; + +const createItems = (...labels: string[]): MenuItem[] => + labels.map((label) => ({ label, onClickItem: vi.fn() })); + +describe('MenuItemPanel', () => { + test('메뉴 아이템을 렌더링한다', () => { + const onMenuClose = vi.fn(); + const { getByText } = render( + + ); + expect(getByText('항목1')).toBeTruthy(); + expect(getByText('항목2')).toBeTruthy(); + }); + + test('visible=false인 항목은 렌더링하지 않는다', () => { + const items: MenuItem[] = [ + { label: '보이는항목', onClickItem: vi.fn() }, + { label: '숨긴항목', onClickItem: vi.fn(), visible: false }, + ]; + const { queryByText } = render(); + expect(queryByText('보이는항목')).toBeTruthy(); + expect(queryByText('숨긴항목')).toBeNull(); + }); + + test('아이템 클릭 시 onClickItem과 onMenuClose를 호출한다', () => { + const items = createItems('항목1'); + const onMenuClose = vi.fn(); + const { getByText } = render( + + ); + fireEvent.click(getByText('항목1')); + expect(items[0].onClickItem).toHaveBeenCalledTimes(1); + expect(onMenuClose).toHaveBeenCalledTimes(1); + }); + + test('HeaderIcon이 있으면 렌더링한다', () => { + const { getByText } = render( + 헤더아이콘} + /> + ); + expect(getByText('헤더아이콘')).toBeTruthy(); + }); + + test('HeaderIcon 클릭 시 onMenuClose를 호출한다', () => { + const onMenuClose = vi.fn(); + const { getByText } = render( + 헤더아이콘} + /> + ); + fireEvent.click(getByText('헤더아이콘')); + expect(onMenuClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/ui/components/profile/profile.component.test.tsx b/src/shared/ui/components/profile/profile.component.test.tsx new file mode 100644 index 00000000..c78119ed --- /dev/null +++ b/src/shared/ui/components/profile/profile.component.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import Profile from './profile.component'; + +describe('Profile', () => { + test('src가 없으면 빈 프로필 SVG가 렌더링된다', () => { + render(); + const svg = screen.getAllByRole('presentation')[0]; + expect(svg.tagName).toBe('svg'); + }); + + test('src가 있으면 div로 렌더링된다 (SVG가 아닌)', () => { + render(); + const el = screen.getByRole('presentation'); + expect(el.tagName).toBe('DIV'); + expect(el.className).toContain('rounded-full'); + }); + + test('size가 width/height에 반영된다', () => { + render(); + const el = screen.getByRole('presentation'); + expect(el.style.width).toBe('64px'); + expect(el.style.height).toBe('64px'); + }); +}); diff --git a/src/shared/ui/components/radio-select-list/radio-select-list.component.test.tsx b/src/shared/ui/components/radio-select-list/radio-select-list.component.test.tsx new file mode 100644 index 00000000..a14c7aa0 --- /dev/null +++ b/src/shared/ui/components/radio-select-list/radio-select-list.component.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { RadioSelectList, RadioSelectListItem } from './radio-select-list.component'; + +vi.mock('@/shared/ui/icons', () => ({ + PFChevronRight: (props: any) => , +})); + +describe('RadioSelectList', () => { + const items: RadioSelectListItem[] = [ + { value: 'a', label: '옵션 A' }, + { value: 'b', label: '옵션 B' }, + { value: 'c', label: '옵션 C' }, + ]; + + test('모든 항목이 렌더링된다', () => { + render(); + + expect(screen.getByText('옵션 A')).toBeTruthy(); + expect(screen.getByText('옵션 B')).toBeTruthy(); + expect(screen.getByText('옵션 C')).toBeTruthy(); + }); + + test('항목 클릭 시 onChange가 해당 value로 호출된다', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('옵션 B')); + expect(onChange).toHaveBeenCalledWith('b'); + }); + + test('선택된 항목에 체크 인디케이터가 표시된다', () => { + const { container } = render(); + + const radios = container.querySelectorAll('[data-checked]'); + const checkedValues = Array.from(radios).map((el) => el.getAttribute('data-checked')); + + expect(checkedValues).toEqual(['false', 'true', 'false']); + }); + + test('선택된 항목 내부에 흰색 원이 표시된다', () => { + const { container } = render(); + + const checked = container.querySelector('[data-checked="true"]'); + expect(checked).not.toBeNull(); + + const innerDot = checked?.querySelector('div'); + expect(innerDot).not.toBeNull(); + }); + + test('빈 목록은 아무것도 렌더링하지 않는다', () => { + const { container } = render(); + + const labels = container.querySelectorAll('label'); + expect(labels.length).toBe(0); + }); +}); diff --git a/src/shared/ui/components/select/select.component.test.tsx b/src/shared/ui/components/select/select.component.test.tsx new file mode 100644 index 00000000..7d1592cb --- /dev/null +++ b/src/shared/ui/components/select/select.component.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import Select, { SelectOption } from './select.component'; + +// Headless UI uses ResizeObserver + +global.ResizeObserver = class ResizeObserver { + public observe() { + /* noop */ + } + public unobserve() { + /* noop */ + } + public disconnect() { + /* noop */ + } +} as any; + +vi.mock('../typography', () => ({ + Typography: ({ children }: any) => {children}, +})); + +vi.mock('@/shared/ui/icons', () => ({ + PFChevronDown: () => , + PFChevronUp: () => , +})); + +const options: SelectOption[] = [ + { value: 'apple', label: '사과' }, + { value: 'banana', label: '바나나' }, + { value: 'cherry', label: '체리' }, +]; + +describe('Select', () => { + test('기본 렌더링 — ListboxButton이 표시된다', () => { + render(); + + expect(screen.getByText('바나나')).toBeTruthy(); + }); + + test('버튼 클릭 시 옵션 목록이 열린다', async () => { + render(); + + fireEvent.click(screen.getByRole('button')); + + const listbox = await screen.findByRole('listbox'); + const optionEls = listbox.querySelectorAll('[role="option"]'); + fireEvent.click(optionEls[2]); // '체리' + + expect(onChange).toHaveBeenCalledWith('cherry'); + }); + + test('prefix/suffix가 있는 옵션이 렌더링된다', async () => { + const optionsWithExtra: SelectOption[] = [ + { value: 'a', label: '항목', prefix: P }, + ]; + + render(