From ba4ccf5425abb311a160b4e9ef81cb0393aec5cc Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 12 May 2026 18:07:31 +0900 Subject: [PATCH 01/39] =?UTF-8?q?docs:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EB=9D=BC=EC=9D=B8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 37 +++++++++++ TESTING.md | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 CLAUDE.md create mode 100644 TESTING.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..734749c7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# 프로젝트 개요 + +web25 부스트캠프 그룹 프로젝트. Next.js(프론트엔드) + NestJS(백엔드) 모노레포 구조. + +- 패키지 매니저: pnpm workspace +- 프론트엔드: `frontend/` — Next.js 16, React 19, TailwindCSS 4, Zustand, TanStack Query +- 백엔드: `backend/` — NestJS 11, TypeORM, PostgreSQL +- 모바일: `mobile-app/` — Capacitor 기반 + +## 주요 커맨드 + +```bash +pnpm dev:fe # 프론트 개발 서버 +pnpm dev:be # 백엔드 개발 서버 +pnpm infra:up # 개발용 Docker 인프라 시작 +pnpm test:fe # 프론트 단위 테스트 (Vitest) +pnpm test:fe:e2e # 프론트 E2E 테스트 (Playwright) +pnpm test:be # 백엔드 단위 테스트 (Jest) +pnpm test:be:e2e # 백엔드 E2E 테스트 +``` + +--- + +# 프론트엔드 테스트 가이드라인 + +> 백엔드(`backend/`)는 Jest를 사용하며 별도 가이드라인 적용. 아래는 `frontend/` 전용. + +자세한 내용은 [TESTING.md](./TESTING.md) 참고. + +## 요약 + +- **Vitest**: 순수 함수, 유틸, 훅 단위 테스트. 파일 위치는 소스 옆 (`*.test.ts`) +- **Playwright**: 사용자 흐름 E2E 테스트. `e2e/` 폴더에 `*.spec.ts`로 분리 +- `getByRole` / `getByLabel` 우선, `data-testid`는 최후 수단 +- `waitForTimeout` 금지 — `expect(...).toBeVisible()` 또는 `waitForResponse` 사용 +- 외부 의존성(`Date`, API)은 반드시 mock 격리 +- `it` 설명은 한국어로, 비즈니스 언어 사용 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..018bb117 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,191 @@ +# 프론트엔드 테스트 코드 작성 가이드라인 + +> `frontend/` 전용 가이드라인 (Vitest + Playwright). 백엔드는 Jest 기반으로 별도 적용. + +## 1. 도구별 역할 분리 + +| 도구 | 대상 | +|------|------| +| **Vitest** | 순수 함수, 유틸, 훅, 비즈니스 로직 단위 테스트 | +| **Playwright** | 사용자 흐름 기반 E2E 테스트 (페이지 이동, 실제 인터랙션) | + +Playwright를 단위 비즈니스 로직 검증에 쓰지 않는다. 렌더링 없이 검증 가능한 로직은 Vitest로 먼저 커버한다. + +--- + +## 2. 파일 구조 및 네이밍 + +``` +src/ + lib/ + utils/ + filterLabels.ts + filterLabels.test.ts ← 소스 파일 옆에 위치 + date.ts + date.test.ts + hooks/ + useDebounce.ts + useDebounce.test.ts +e2e/ + record-create.spec.ts ← Playwright E2E + group-invite.spec.ts +``` + +- Vitest: `*.test.ts` — 소스 파일과 같은 폴더 +- Playwright: `*.spec.ts` — `e2e/` 폴더에 분리 + +--- + +## 3. Vitest 작성 규칙 + +### 3-1. describe/it 구조 + +```typescript +// src/lib/utils/filterLabels.test.ts +import { makeTagLabel } from './filterLabels'; + +describe('makeTagLabel', () => { + it('태그가 없으면 기본값 "태그" 반환', () => { + expect(makeTagLabel([])).toBe('태그'); + }); + + it('태그가 1개이면 태그명 그대로 반환', () => { + expect(makeTagLabel(['여행'])).toBe('여행'); + }); + + it('태그가 2개 이상이면 "첫번째 외 N" 형식 반환', () => { + expect(makeTagLabel(['여행', '맛집', '카페'])).toBe('여행 외 2'); + }); +}); +``` + +- `describe`: 함수/모듈 단위 +- `it`: 단일 시나리오. **한국어로** 무엇을 기대하는지 명시 +- 하나의 `it` 블록에 하나의 `expect` 원칙 (불가피한 경우 예외) + +### 3-2. 경계값과 예외 케이스를 반드시 포함 + +```typescript +describe('makeDateLabel', () => { + it('start, end 둘 다 없으면 "날짜" 반환', () => { ... }); + it('start만 있으면 start 반환', () => { ... }); + it('end만 있으면 end 반환', () => { ... }); + it('start와 end 모두 있으면 "start ~ end" 반환', () => { ... }); + it('null을 받으면 기본값 반환', () => { ... }); // ← 경계값 +}); +``` + +### 3-3. 외부 의존성은 Mock으로 격리 + +```typescript +// API 호출, 날짜(Date), 랜덤값은 반드시 mock +vi.useFakeTimers(); +vi.setSystemTime(new Date('2025-01-15')); + +// fetch/API mock +vi.mock('@/lib/api/record', () => ({ + fetchRecord: vi.fn().mockResolvedValue({ id: '1', title: '테스트' }), +})); +``` + +- `Date`, `Math.random`, 외부 API는 테스트마다 결과가 달라지므로 mock 필수 +- `afterEach(() => vi.restoreAllMocks())` 로 상태 초기화 + +### 3-4. 훅 테스트 + +```typescript +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +describe('useDebounce', () => { + it('delay 이전에는 초기값 유지', async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useDebounce('초기', 500)); + expect(result.current).toBe('초기'); + vi.clearAllTimers(); + }); +}); +``` + +--- + +## 4. Playwright 작성 규칙 + +### 4-1. 사용자 관점으로 작성 + +```typescript +// e2e/record-create.spec.ts +test('기록 생성 후 목록에 나타난다', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '기록 추가' }).click(); + await page.getByLabel('제목').fill('제주도 여행'); + await page.getByRole('button', { name: '저장' }).click(); + + await expect(page.getByText('제주도 여행')).toBeVisible(); +}); +``` + +- `getByRole`, `getByLabel`, `getByText` 우선 — `data-testid`는 최후 수단 +- 구현 세부사항(클래스명, DOM 구조)에 의존하지 않음 + +### 4-2. 페이지 객체 패턴 (Page Object Model) + +반복되는 인터랙션은 POM으로 추출: + +```typescript +// e2e/pages/RecordPage.ts +export class RecordPage { + constructor(private page: Page) {} + + async createRecord(title: string) { + await this.page.getByRole('button', { name: '기록 추가' }).click(); + await this.page.getByLabel('제목').fill(title); + await this.page.getByRole('button', { name: '저장' }).click(); + } +} +``` + +### 4-3. 인증 상태 재사용 + +```typescript +// playwright.config.ts에서 storageState로 로그인 상태 저장 +setup('로그인', async ({ page }) => { + await page.goto('/login'); + // ... 로그인 처리 + await page.context().storageState({ path: 'e2e/.auth/user.json' }); +}); +``` + +매 테스트마다 로그인하지 않고 `storageState`를 재사용한다. + +### 4-4. 불안정한 패턴 금지 + +```typescript +// ❌ 금지 +await page.waitForTimeout(2000); + +// ✅ 대신 +await expect(page.getByText('저장됨')).toBeVisible(); +await page.waitForResponse('**/api/records'); +``` + +--- + +## 5. 공통 원칙 + +| 원칙 | 내용 | +|------|------| +| **AAA 패턴** | Arrange(준비) → Act(실행) → Assert(검증) 순서 유지 | +| **테스트 독립성** | 각 테스트는 다른 테스트의 실행 순서나 상태에 의존하지 않음 | +| **비즈니스 언어 사용** | `it('userId가 null이면...')` 보다 `it('로그인하지 않은 사용자는...')` | +| **커버리지보다 신뢰성** | 100% 커버리지보다 핵심 경로의 신뢰할 수 있는 테스트가 우선 | +| **테스트도 코드다** | 중복 제거, 헬퍼 추출 — 단, 과도한 추상화는 가독성을 해침 | + +--- + +## 6. 우선순위 (무엇부터 테스트할 것인가) + +1. **순수 유틸 함수** — `filterLabels`, `date`, `record` 유틸 (가장 쉽고 ROI가 높음) +2. **복잡한 비즈니스 훅** — `useGroupActions`, `useRecordCollaboration` +3. **핵심 사용자 흐름** — 기록 생성/조회, 그룹 초대 (Playwright) +4. **엣지 케이스** — 권한 없는 접근, 네트워크 오류 처리 From c7d434b7f27debb9c6e266266ca8454071262932 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 12 May 2026 20:36:21 +0900 Subject: [PATCH 02/39] =?UTF-8?q?test:=20Vitest=20=EA=B8=B0=EB=B0=98=20hoo?= =?UTF-8?q?ks=C2=B7=EC=9C=A0=ED=8B=B8=C2=B7=EC=8A=A4=ED=86=A0=EC=96=B4=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + TESTING.md | 50 +++ frontend/package.json | 3 + frontend/src/hooks/useDebounce.test.ts | 76 ++++ .../src/hooks/useScrollDirection.test.tsx | 69 ++++ frontend/src/hooks/useSessionStorage.test.ts | 84 +++++ frontend/src/lib/date.test.ts | 178 +++++++++ frontend/src/lib/utils/cookie.test.ts | 37 ++ frontend/src/lib/utils/errorHandler.test.ts | 112 ++++++ frontend/src/lib/utils/filterLabels.test.ts | 77 ++++ .../src/lib/utils/mapBlocksToPayload.test.ts | 92 +++++ frontend/src/lib/utils/record.test.ts | 56 +++ frontend/src/lib/utils/time.test.ts | 86 +++++ frontend/src/lib/utils/useDebounce.test.ts | 82 +++++ frontend/src/lib/utils/useThrottle.test.ts | 81 ++++ .../store/useLocationPermissionStore.test.ts | 76 ++++ frontend/vitest.config.ts | 15 +- pnpm-lock.yaml | 345 +++++++++++++++++- 18 files changed, 1512 insertions(+), 8 deletions(-) create mode 100644 frontend/src/hooks/useDebounce.test.ts create mode 100644 frontend/src/hooks/useScrollDirection.test.tsx create mode 100644 frontend/src/hooks/useSessionStorage.test.ts create mode 100644 frontend/src/lib/date.test.ts create mode 100644 frontend/src/lib/utils/cookie.test.ts create mode 100644 frontend/src/lib/utils/errorHandler.test.ts create mode 100644 frontend/src/lib/utils/filterLabels.test.ts create mode 100644 frontend/src/lib/utils/mapBlocksToPayload.test.ts create mode 100644 frontend/src/lib/utils/record.test.ts create mode 100644 frontend/src/lib/utils/time.test.ts create mode 100644 frontend/src/lib/utils/useDebounce.test.ts create mode 100644 frontend/src/lib/utils/useThrottle.test.ts create mode 100644 frontend/src/store/useLocationPermissionStore.test.ts diff --git a/.gitignore b/.gitignore index 2189378d..2ba50d09 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules # env files (can opt-in for committing if needed) .env* !.env.example +.superset/* \ No newline at end of file diff --git a/TESTING.md b/TESTING.md index 018bb117..5bd2b06b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -189,3 +189,53 @@ await page.waitForResponse('**/api/records'); 2. **복잡한 비즈니스 훅** — `useGroupActions`, `useRecordCollaboration` 3. **핵심 사용자 흐름** — 기록 생성/조회, 그룹 초대 (Playwright) 4. **엣지 케이스** — 권한 없는 접근, 네트워크 오류 처리 + +--- + +## 7. 실전 교훈 + +### 7-1. fake timer + React state는 `act`를 반드시 분리 + +`vi.useFakeTimers()`와 `rerender`를 같은 `act` 블록에 넣으면 타이머 실행 후 React state flush 순서가 꼬여 값이 반영되지 않는다. + +```typescript +// ❌ 같은 act 안에서 rerender + advanceTimersByTime +act(() => { + rerender({ value: '변경' }); + vi.advanceTimersByTime(500); +}); +expect(result.current).toBe('변경'); // 실패 — 값이 '초기' 그대로 + +// ✅ act를 분리 +act(() => { rerender({ value: '변경' }); }); +act(() => { vi.advanceTimersByTime(500); }); // 타이머 실행 후 React state flush +expect(result.current).toBe('변경'); // 통과 +``` + +### 7-2. DOM ref에 의존하는 훅은 `renderHook` 대신 `render` 사용 + +`useEffect(fn, [])` 내부에서 `ref.current`를 읽는 훅은 `renderHook`으로 테스트하면 ref가 항상 `null`이다. `renderHook`은 실제 DOM 요소를 렌더링하지 않기 때문이다. + +```typescript +// ❌ renderHook — useEffect 시점에 containerRef.current가 null +const { result } = renderHook(() => useScrollDirection()); +result.current.containerRef.current = container; // 이미 effect가 종료된 후라 이벤트 리스너가 붙지 않음 + +// ✅ render로 실제 DOM에 ref 연결 — effect 실행 전에 ref가 채워짐 +function TestComponent() { + const { containerRef, isVisible } = useScrollDirection(); + return
; +} +render(); +``` + +### 7-3. JSX를 포함하는 테스트 파일은 `.tsx` 확장자 + +`.ts` 파일에 JSX를 작성하면 esbuild transform 오류가 발생한다. + +``` +// ❌ useScrollDirection.test.ts → "Expected '>' but found 'data'" 오류 +// ✅ useScrollDirection.test.tsx → 정상 변환 +``` + +JSX가 없는 순수 로직 테스트는 `.test.ts`, 컴포넌트나 JSX를 포함하면 `.test.tsx`로 작성한다. diff --git a/frontend/package.json b/frontend/package.json index f3bded73..0d25e6d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,8 @@ "@storybook/addon-vitest": "^10.1.11", "@storybook/nextjs-vite": "^10.1.11", "@tailwindcss/postcss": "^4", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/google.maps": "^3.58.1", "@types/node": "^20", "@types/react": "^19", @@ -78,6 +80,7 @@ "eslint": "^9", "eslint-config-next": "16.0.10", "eslint-plugin-storybook": "^10.1.11", + "jsdom": "^29.1.1", "msw": "^2.12.4", "msw-storybook-addon": "^2.0.6", "playwright": "^1.57.0", diff --git a/frontend/src/hooks/useDebounce.test.ts b/frontend/src/hooks/useDebounce.test.ts new file mode 100644 index 00000000..fca57e70 --- /dev/null +++ b/frontend/src/hooks/useDebounce.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedValue } from './useDebounce'; + +describe('useDebouncedValue', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('초기값을 즉시 반환한다', () => { + const { result } = renderHook(() => useDebouncedValue('초기', 500)); + expect(result.current).toBe('초기'); + }); + + it('delay 이전에는 값이 변경되지 않는다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { + rerender({ value: '변경' }); + }); + + expect(result.current).toBe('초기'); + }); + + it('delay 이후에 변경된 값을 반환한다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { rerender({ value: '변경' }); }); + act(() => { vi.advanceTimersByTime(500); }); + + expect(result.current).toBe('변경'); + }); + + it('연속으로 값이 바뀌면 마지막 값만 반영된다', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { rerender({ value: '첫 번째' }); }); + act(() => { rerender({ value: '두 번째' }); }); + act(() => { rerender({ value: '세 번째' }); }); + act(() => { vi.advanceTimersByTime(500); }); + + expect(result.current).toBe('세 번째'); + }); + + it('unmount 시 타이머가 정리된다', () => { + const { result, rerender, unmount } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: '초기' } }, + ); + + act(() => { + rerender({ value: '변경' }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('초기'); + }); +}); diff --git a/frontend/src/hooks/useScrollDirection.test.tsx b/frontend/src/hooks/useScrollDirection.test.tsx new file mode 100644 index 00000000..347b421a --- /dev/null +++ b/frontend/src/hooks/useScrollDirection.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { render, act, screen } from '@testing-library/react'; +import React from 'react'; +import { useScrollDirection } from './useScrollDirection'; + +// renderHook은 DOM 요소 없이 실행되어 useEffect 시점에 containerRef.current가 null이 됨. +// render로 실제 DOM 요소에 ref를 연결한 뒤 scroll 이벤트를 시뮬레이션한다. +function ScrollTestComponent() { + const { containerRef, isVisible } = useScrollDirection(); + return ( +
+ ); +} + +function getVisible(): boolean { + return screen.getByTestId('container').getAttribute('data-visible') === 'true'; +} + +function scrollTo(scrollTop: number) { + const el = screen.getByTestId('container'); + Object.defineProperty(el, 'scrollTop', { + value: scrollTop, + writable: true, + configurable: true, + }); + act(() => { + el.dispatchEvent(new Event('scroll')); + }); +} + +describe('useScrollDirection', () => { + it('초기 상태는 isVisible이 true다', () => { + render(); + expect(getVisible()).toBe(true); + }); + + it('아래로 스크롤하면 isVisible이 false가 된다', () => { + render(); + scrollTo(100); + expect(getVisible()).toBe(false); + }); + + it('아래로 스크롤 후 위로 스크롤하면 isVisible이 true로 돌아온다', () => { + render(); + scrollTo(100); + scrollTo(30); + expect(getVisible()).toBe(true); + }); + + it('최상단(scrollTop <= 10)에서는 항상 isVisible이 true다', () => { + render(); + scrollTo(100); + expect(getVisible()).toBe(false); + scrollTo(5); + expect(getVisible()).toBe(true); + }); + + it('5px 미만 미세 스크롤은 상태를 변경하지 않는다', () => { + render(); + scrollTo(50); + expect(getVisible()).toBe(false); + scrollTo(53); // 3px — 임계값 미만 + expect(getVisible()).toBe(false); + }); +}); diff --git a/frontend/src/hooks/useSessionStorage.test.ts b/frontend/src/hooks/useSessionStorage.test.ts new file mode 100644 index 00000000..6c37556b --- /dev/null +++ b/frontend/src/hooks/useSessionStorage.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSessionStorage } from './useSessionStorage'; + +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); +vi.mock('@/lib/utils/logger', () => ({ logger: { error: vi.fn() } })); + +describe('useSessionStorage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('초기값을 반환한다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + const [value] = result.current; + expect(value).toBe('초기값'); + }); + + it('setValue로 값을 변경하면 상태와 sessionStorage가 모두 업데이트된다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + act(() => { + result.current[1]('변경된 값'); + }); + + expect(result.current[0]).toBe('변경된 값'); + expect(JSON.parse(sessionStorage.getItem('test-key')!)).toBe('변경된 값'); + }); + + it('함수형 업데이트를 지원한다', () => { + const { result } = renderHook(() => useSessionStorage('count', 0)); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(1); + }); + + it('removeValue 호출 시 초기값으로 돌아가고 sessionStorage에서 제거된다', () => { + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + act(() => { + result.current[1]('저장된 값'); + }); + + act(() => { + result.current[2](); + }); + + expect(result.current[0]).toBe('초기값'); + expect(sessionStorage.getItem('test-key')).toBeNull(); + }); + + it('객체 값을 JSON으로 직렬화해서 저장한다', () => { + const initialValue = { name: '테스트', count: 0 }; + const { result } = renderHook(() => + useSessionStorage('test-obj', initialValue), + ); + + act(() => { + result.current[1]({ name: '변경', count: 1 }); + }); + + const stored = JSON.parse(sessionStorage.getItem('test-obj')!); + expect(stored).toEqual({ name: '변경', count: 1 }); + }); + + it('sessionStorage에 기존 값이 있으면 초기값 대신 저장된 값을 반환한다', () => { + sessionStorage.setItem('test-key', JSON.stringify('기존 값')); + + const { result } = renderHook(() => useSessionStorage('test-key', '초기값')); + + expect(result.current[0]).toBe('기존 값'); + }); + + it('sessionStorage 읽기 실패 시 초기값을 반환한다', () => { + sessionStorage.setItem('bad-key', 'invalid json {{{'); + + const { result } = renderHook(() => useSessionStorage('bad-key', '초기값')); + + expect(result.current[0]).toBe('초기값'); + }); +}); diff --git a/frontend/src/lib/date.test.ts b/frontend/src/lib/date.test.ts new file mode 100644 index 00000000..8e168acb --- /dev/null +++ b/frontend/src/lib/date.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + formatRelativeTime, + getMonthRange, + getWeekDays, + getStartOfWeek, + formatDateISO, + parseLocalDate, + formatDotDateString, + getWeekdayFromDotString, +} from './date'; + +describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-15T12:00:00')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('60초 미만이면 "방금 전" 반환', () => { + const date = new Date('2024-06-15T11:59:10'); + expect(formatRelativeTime(date)).toBe('방금 전'); + }); + + it('1분~59분이면 "N분 전" 반환', () => { + const date = new Date('2024-06-15T11:55:00'); + expect(formatRelativeTime(date)).toBe('5분 전'); + }); + + it('1시간~23시간이면 "N시간 전" 반환', () => { + const date = new Date('2024-06-15T09:00:00'); + expect(formatRelativeTime(date)).toBe('3시간 전'); + }); + + it('정확히 1일 전이면 "어제" 반환', () => { + const date = new Date('2024-06-14T12:00:00'); + expect(formatRelativeTime(date)).toBe('어제'); + }); + + it('2일~29일이면 "N일 전" 반환', () => { + const date = new Date('2024-06-10T12:00:00'); + expect(formatRelativeTime(date)).toBe('5일 전'); + }); + + it('1개월~11개월이면 "N개월 전" 반환', () => { + const date = new Date('2024-03-15T12:00:00'); + expect(formatRelativeTime(date)).toBe('3개월 전'); + }); + + it('12개월 이상이면 "N년 전" 반환', () => { + const date = new Date('2023-06-15T12:00:00'); + expect(formatRelativeTime(date)).toBe('1년 전'); + }); +}); + +describe('getMonthRange', () => { + it('일반 달의 시작일과 종료일을 반환한다', () => { + expect(getMonthRange('2024-03')).toEqual({ + startDate: '2024-03-01', + endDate: '2024-03-31', + }); + }); + + it('윤년 2월의 마지막 날은 29일이다', () => { + expect(getMonthRange('2024-02')).toEqual({ + startDate: '2024-02-01', + endDate: '2024-02-29', + }); + }); + + it('평년 2월의 마지막 날은 28일이다', () => { + expect(getMonthRange('2023-02')).toEqual({ + startDate: '2023-02-01', + endDate: '2023-02-28', + }); + }); + + it('30일짜리 달의 종료일은 30일이다', () => { + expect(getMonthRange('2024-04')).toEqual({ + startDate: '2024-04-01', + endDate: '2024-04-30', + }); + }); + + it('12월의 마지막 날은 31일이다', () => { + expect(getMonthRange('2024-12')).toEqual({ + startDate: '2024-12-01', + endDate: '2024-12-31', + }); + }); +}); + +describe('getStartOfWeek', () => { + it('주의 시작은 일요일이다', () => { + const wednesday = new Date('2024-06-12'); // 수요일 + const startOfWeek = getStartOfWeek(wednesday); + expect(startOfWeek.getDay()).toBe(0); // 일요일 + }); + + it('일요일을 입력하면 같은 날을 반환한다', () => { + const sunday = new Date('2024-06-09'); + const startOfWeek = getStartOfWeek(sunday); + expect(formatDateISO(startOfWeek)).toBe('2024-06-09'); + }); + + it('시간을 00:00:00으로 초기화한다', () => { + const date = new Date('2024-06-12T15:30:00'); + const startOfWeek = getStartOfWeek(date); + expect(startOfWeek.getHours()).toBe(0); + expect(startOfWeek.getMinutes()).toBe(0); + expect(startOfWeek.getSeconds()).toBe(0); + }); +}); + +describe('getWeekDays', () => { + it('7개의 요일을 반환한다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days).toHaveLength(7); + }); + + it('첫 번째 날은 일요일이다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days[0].dayName).toBe('일'); + }); + + it('마지막 날은 토요일이다', () => { + const days = getWeekDays(new Date('2024-06-12')); + expect(days[6].dayName).toBe('토'); + }); + + it('오늘 날짜에 isToday가 true다', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-12')); + const days = getWeekDays(new Date('2024-06-12')); + const today = days.find((d) => d.isToday); + expect(today).toBeDefined(); + expect(today?.dateStr).toBe('2024-06-12'); + vi.useRealTimers(); + }); +}); + +describe('parseLocalDate', () => { + it('YYYY-MM-DD 문자열을 로컬 Date로 변환한다', () => { + const date = parseLocalDate('2024-06-15'); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(5); // 0-indexed + expect(date.getDate()).toBe(15); + }); + + it('UTC 해석 없이 로컬 자정을 반환한다', () => { + const date = parseLocalDate('2024-01-01'); + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(0); + }); +}); + +describe('formatDotDateString', () => { + it('ISO 날짜 문자열을 YYYY.MM.DD로 변환한다', () => { + expect(formatDotDateString('2025-12-20T20:00:00Z')).toBe('2025.12.20'); + }); + + it('날짜만 있는 문자열도 변환한다', () => { + expect(formatDotDateString('2025-12-21')).toBe('2025.12.21'); + }); +}); + +describe('getWeekdayFromDotString', () => { + it('수요일 날짜 문자열에서 "수"를 반환한다', () => { + expect(getWeekdayFromDotString('2024.06.12')).toBe('수'); + }); + + it('일요일 날짜 문자열에서 "일"을 반환한다', () => { + expect(getWeekdayFromDotString('2024.06.09')).toBe('일'); + }); +}); diff --git a/frontend/src/lib/utils/cookie.test.ts b/frontend/src/lib/utils/cookie.test.ts new file mode 100644 index 00000000..6ee3ce82 --- /dev/null +++ b/frontend/src/lib/utils/cookie.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { getCookieFromString } from './cookie'; + +describe('getCookieFromString', () => { + it('단일 쿠키에서 값을 반환한다', () => { + expect(getCookieFromString('token=abc123', 'token')).toBe('abc123'); + }); + + it('여러 쿠키 중 해당하는 값을 반환한다', () => { + const cookieStr = 'token=abc123; userId=42; theme=dark'; + expect(getCookieFromString(cookieStr, 'userId')).toBe('42'); + }); + + it('쿠키 이름이 없으면 null을 반환한다', () => { + expect(getCookieFromString('token=abc123', 'notExist')).toBeNull(); + }); + + it('빈 쿠키 문자열이면 null을 반환한다', () => { + expect(getCookieFromString('', 'token')).toBeNull(); + }); + + it('URL 인코딩된 값을 디코딩해서 반환한다', () => { + const encoded = encodeURIComponent('hello world'); + const name = encodeURIComponent('key'); + expect(getCookieFromString(`${name}=${encoded}`, 'key')).toBe('hello world'); + }); + + it('쿠키 이름이 다른 쿠키의 접두사여도 정확히 매칭한다', () => { + const cookieStr = 'token=abc; tokenExtra=xyz'; + expect(getCookieFromString(cookieStr, 'token')).toBe('abc'); + }); + + it('공백이 포함된 쿠키 문자열을 파싱한다', () => { + const cookieStr = ' token=abc123 ; userId=42'; + expect(getCookieFromString(cookieStr, 'token')).toBe('abc123'); + }); +}); diff --git a/frontend/src/lib/utils/errorHandler.test.ts b/frontend/src/lib/utils/errorHandler.test.ts new file mode 100644 index 00000000..85121e7b --- /dev/null +++ b/frontend/src/lib/utils/errorHandler.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + isAuthError, + getErrorMessage, + createApiError, + ERROR_CODES, +} from './errorHandler'; +import type { ApiResponse } from '../types/response'; + +describe('isAuthError', () => { + it('TOKEN_EXPIRED는 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.TOKEN_EXPIRED)).toBe(true); + }); + + it('INVALID_TOKEN은 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.INVALID_TOKEN)).toBe(true); + }); + + it('UNAUTHORIZED는 인증 에러다', () => { + expect(isAuthError(ERROR_CODES.UNAUTHORIZED)).toBe(true); + }); + + it('NETWORK_ERROR는 인증 에러가 아니다', () => { + expect(isAuthError(ERROR_CODES.NETWORK_ERROR)).toBe(false); + }); + + it('알 수 없는 에러 코드는 인증 에러가 아니다', () => { + expect(isAuthError('SOME_OTHER_ERROR')).toBe(false); + }); + + it('undefined이면 인증 에러가 아니다', () => { + expect(isAuthError(undefined)).toBe(false); + }); +}); + +describe('getErrorMessage', () => { + it('ApiError 객체의 message를 반환한다', () => { + const error = { message: '토큰이 만료되었습니다.' }; + expect(getErrorMessage(error)).toBe('토큰이 만료되었습니다.'); + }); + + it('Error 인스턴스의 message를 반환한다', () => { + const error = new Error('네트워크 오류'); + expect(getErrorMessage(error)).toBe('네트워크 오류'); + }); + + it('문자열 에러를 그대로 반환한다', () => { + expect(getErrorMessage('서버 오류')).toBe('서버 오류'); + }); + + it('null이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage(null)).toBe('알 수 없는 오류가 발생했습니다.'); + }); + + it('undefined이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage(undefined)).toBe('알 수 없는 오류가 발생했습니다.'); + }); + + it('빈 메시지 객체이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage({ message: ' ' })).toBe( + '알 수 없는 오류가 발생했습니다.', + ); + }); + + it('빈 문자열이면 기본 메시지를 반환한다', () => { + expect(getErrorMessage('')).toBe('알 수 없는 오류가 발생했습니다.'); + }); +}); + +describe('createApiError', () => { + it('에러 응답에서 에러 객체를 생성한다', () => { + const response: ApiResponse = { + success: false, + error: { message: '권한이 없습니다.', code: 'UNAUTHORIZED' }, + }; + const error = createApiError(response); + + expect(error.message).toBe('권한이 없습니다.'); + expect(error.code).toBe('UNAUTHORIZED'); + expect(error.isAuthError).toBe(true); + }); + + it('NETWORK_ERROR 코드이면 isAuthError가 false다', () => { + const response: ApiResponse = { + success: false, + error: { message: '네트워크 오류', code: 'NETWORK_ERROR' }, + }; + const error = createApiError(response); + + expect(error.isAuthError).toBe(false); + }); + + it('success 응답이 들어오면 기본 에러를 반환한다', () => { + const response: ApiResponse = { + success: true, + data: {}, + }; + const error = createApiError(response); + + expect(error.message).toBe('알 수 없는 서버 응답입니다.'); + }); + + it('에러 코드가 없으면 INTERNAL_SERVER_ERROR로 설정된다', () => { + const response: ApiResponse = { + success: false, + error: { message: '서버 오류' }, + }; + const error = createApiError(response); + + expect(error.code).toBe('INTERNAL_SERVER_ERROR'); + }); +}); diff --git a/frontend/src/lib/utils/filterLabels.test.ts b/frontend/src/lib/utils/filterLabels.test.ts new file mode 100644 index 00000000..e1f9e9cd --- /dev/null +++ b/frontend/src/lib/utils/filterLabels.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { + makeTagLabel, + makeEmotionLabel, + makeDateLabel, + makeLocationLabel, +} from './filterLabels'; + +describe('makeTagLabel', () => { + it('태그가 없으면 "태그" 반환', () => { + expect(makeTagLabel([])).toBe('태그'); + }); + + it('태그가 1개이면 태그명 그대로 반환', () => { + expect(makeTagLabel(['여행'])).toBe('여행'); + }); + + it('태그가 2개이면 "첫번째 외 1" 반환', () => { + expect(makeTagLabel(['여행', '맛집'])).toBe('여행 외 1'); + }); + + it('태그가 3개 이상이면 "첫번째 외 N" 반환', () => { + expect(makeTagLabel(['여행', '맛집', '카페'])).toBe('여행 외 2'); + }); +}); + +describe('makeEmotionLabel', () => { + it('감정이 없으면 "감정" 반환', () => { + expect(makeEmotionLabel([])).toBe('감정'); + }); + + it('감정이 1개이면 감정명 그대로 반환', () => { + expect(makeEmotionLabel(['행복'])).toBe('행복'); + }); + + it('감정이 2개 이상이면 "첫번째 외 N" 반환', () => { + expect(makeEmotionLabel(['행복', '설렘', '평온'])).toBe('행복 외 2'); + }); +}); + +describe('makeDateLabel', () => { + it('start, end 모두 없으면 "날짜" 반환', () => { + expect(makeDateLabel()).toBe('날짜'); + }); + + it('start, end 모두 null이면 "날짜" 반환', () => { + expect(makeDateLabel(null, null)).toBe('날짜'); + }); + + it('start만 있으면 start 반환', () => { + expect(makeDateLabel('2024-01-01')).toBe('2024-01-01'); + }); + + it('end만 있으면 end 반환', () => { + expect(makeDateLabel(null, '2024-12-31')).toBe('2024-12-31'); + }); + + it('start, end 모두 있으면 "start ~ end" 반환', () => { + expect(makeDateLabel('2024-01-01', '2024-12-31')).toBe( + '2024-01-01 ~ 2024-12-31', + ); + }); +}); + +describe('makeLocationLabel', () => { + it('주소가 없으면 "장소" 반환', () => { + expect(makeLocationLabel()).toBe('장소'); + }); + + it('주소가 null이면 "장소" 반환', () => { + expect(makeLocationLabel(null)).toBe('장소'); + }); + + it('주소가 있으면 주소 그대로 반환', () => { + expect(makeLocationLabel('서울특별시 강남구')).toBe('서울특별시 강남구'); + }); +}); diff --git a/frontend/src/lib/utils/mapBlocksToPayload.test.ts b/frontend/src/lib/utils/mapBlocksToPayload.test.ts new file mode 100644 index 00000000..b13384b6 --- /dev/null +++ b/frontend/src/lib/utils/mapBlocksToPayload.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { mapBlocksToPayload, RecordFieldtypeMap, ServerToFieldTypeMap } from './mapBlocksToPayload'; +import type { RecordBlock } from '../types/record'; + +const layout = { row: 0, col: 0, span: 2 }; + +const sampleBlocks: RecordBlock[] = [ + { id: 'block-1', type: 'content', value: { text: '내용' }, layout }, + { id: 'block-2', type: 'emotion', value: { mood: '행복' }, layout }, + { id: 'block-3', type: 'tags', value: { tags: ['여행'] }, layout }, + { id: 'block-4', type: 'rating', value: { rating: 5 }, layout }, + { id: 'block-5', type: 'date', value: { date: '2024-01-01' }, layout }, +]; + +describe('mapBlocksToPayload', () => { + it('프론트 타입을 서버 타입으로 변환한다', () => { + const result = mapBlocksToPayload(sampleBlocks); + expect(result[0].type).toBe('TEXT'); + expect(result[1].type).toBe('MOOD'); + expect(result[2].type).toBe('TAG'); + expect(result[3].type).toBe('RATING'); + expect(result[4].type).toBe('DATE'); + }); + + it('includeId가 false이면 id를 포함하지 않는다', () => { + const result = mapBlocksToPayload(sampleBlocks, false); + result.forEach((block) => { + expect(block.id).toBeUndefined(); + }); + }); + + it('includeId가 true이면 id를 포함한다', () => { + const result = mapBlocksToPayload(sampleBlocks, true); + expect(result[0].id).toBe('block-1'); + expect(result[1].id).toBe('block-2'); + }); + + it('기본값은 includeId false다', () => { + const result = mapBlocksToPayload(sampleBlocks); + result.forEach((block) => { + expect(block.id).toBeUndefined(); + }); + }); + + it('value와 layout을 그대로 전달한다', () => { + const result = mapBlocksToPayload(sampleBlocks); + expect(result[0].value).toEqual({ text: '내용' }); + expect(result[0].layout).toEqual(layout); + }); + + it('알 수 없는 블록 타입이면 에러를 던진다', () => { + const invalidBlock = [ + { id: 'x', type: 'unknown', value: {}, layout }, + ] as unknown as RecordBlock[]; + + expect(() => mapBlocksToPayload(invalidBlock)).toThrowError( + 'Unknown block type: unknown', + ); + }); + + it('빈 배열이면 빈 배열을 반환한다', () => { + expect(mapBlocksToPayload([])).toEqual([]); + }); +}); + +describe('RecordFieldtypeMap', () => { + it('content는 TEXT로 매핑된다', () => { + expect(RecordFieldtypeMap['content']).toBe('TEXT'); + }); + + it('emotion은 MOOD로 매핑된다', () => { + expect(RecordFieldtypeMap['emotion']).toBe('MOOD'); + }); + + it('photos는 IMAGE로 매핑된다', () => { + expect(RecordFieldtypeMap['photos']).toBe('IMAGE'); + }); +}); + +describe('ServerToFieldTypeMap', () => { + it('TEXT는 content로 매핑된다', () => { + expect(ServerToFieldTypeMap['TEXT']).toBe('content'); + }); + + it('MOOD는 emotion으로 매핑된다', () => { + expect(ServerToFieldTypeMap['MOOD']).toBe('emotion'); + }); + + it('IMAGE는 photos로 매핑된다', () => { + expect(ServerToFieldTypeMap['IMAGE']).toBe('photos'); + }); +}); diff --git a/frontend/src/lib/utils/record.test.ts b/frontend/src/lib/utils/record.test.ts new file mode 100644 index 00000000..ce4b41a8 --- /dev/null +++ b/frontend/src/lib/utils/record.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { getBlockValue, getBlockValues } from './record'; +import type { RecordBlock } from '../types/record'; + +const layout = { row: 0, col: 0, span: 2 }; + +const blocks: RecordBlock[] = [ + { id: '1', type: 'content', value: { text: '오늘의 기록' }, layout }, + { id: '2', type: 'emotion', value: { mood: '행복' }, layout }, + { id: '3', type: 'tags', value: { tags: ['여행', '맛집'] }, layout }, + { id: '4', type: 'content', value: { text: '두 번째 내용' }, layout }, + { id: '5', type: 'rating', value: { rating: 4 }, layout }, +]; + +describe('getBlockValue', () => { + it('존재하는 타입의 첫 번째 블록 값을 반환한다', () => { + expect(getBlockValue(blocks, 'content')).toEqual({ text: '오늘의 기록' }); + }); + + it('같은 타입이 여러 개면 첫 번째만 반환한다', () => { + const result = getBlockValue(blocks, 'content'); + expect(result).toEqual({ text: '오늘의 기록' }); + }); + + it('emotion 타입의 값을 반환한다', () => { + expect(getBlockValue(blocks, 'emotion')).toEqual({ mood: '행복' }); + }); + + it('존재하지 않는 타입이면 undefined를 반환한다', () => { + expect(getBlockValue(blocks, 'date')).toBeUndefined(); + }); + + it('블록 배열이 비어 있으면 undefined를 반환한다', () => { + expect(getBlockValue([], 'content')).toBeUndefined(); + }); +}); + +describe('getBlockValues', () => { + it('존재하는 타입의 모든 블록 값을 배열로 반환한다', () => { + const result = getBlockValues(blocks, 'content'); + expect(result).toEqual([{ text: '오늘의 기록' }, { text: '두 번째 내용' }]); + }); + + it('타입이 1개만 있으면 요소 1개짜리 배열을 반환한다', () => { + const result = getBlockValues(blocks, 'emotion'); + expect(result).toEqual([{ mood: '행복' }]); + }); + + it('존재하지 않는 타입이면 빈 배열을 반환한다', () => { + expect(getBlockValues(blocks, 'location')).toEqual([]); + }); + + it('블록 배열이 비어 있으면 빈 배열을 반환한다', () => { + expect(getBlockValues([], 'content')).toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/time.test.ts b/frontend/src/lib/utils/time.test.ts new file mode 100644 index 00000000..784a9f3e --- /dev/null +++ b/frontend/src/lib/utils/time.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { convertTo12Hour, convertTo24Hour, getPastDate } from './time'; + +describe('convertTo12Hour', () => { + it('오전 시간을 변환한다', () => { + expect(convertTo12Hour('09:30')).toBe('오전 9:30'); + }); + + it('오후 시간을 변환한다', () => { + expect(convertTo12Hour('23:23')).toBe('오후 11:23'); + }); + + it('자정(00:00)은 오전 12시로 변환한다', () => { + expect(convertTo12Hour('00:15')).toBe('오전 12:15'); + }); + + it('정오(12:00)는 오후 12시로 변환한다', () => { + expect(convertTo12Hour('12:00')).toBe('오후 12:00'); + }); + + it('정오 이후(12:30)는 오후 12시로 변환한다', () => { + expect(convertTo12Hour('12:30')).toBe('오후 12:30'); + }); + + it('빈 문자열이면 그대로 반환한다', () => { + expect(convertTo12Hour('')).toBe(''); + }); + + it('콜론이 없는 잘못된 형식이면 그대로 반환한다', () => { + expect(convertTo12Hour('1230')).toBe('1230'); + }); +}); + +describe('convertTo24Hour', () => { + it('오전 시간을 변환한다', () => { + expect(convertTo24Hour('오전 9:30')).toBe('09:30'); + }); + + it('오후 시간을 변환한다', () => { + expect(convertTo24Hour('오후 11:23')).toBe('23:23'); + }); + + it('오전 12시는 00시로 변환한다', () => { + expect(convertTo24Hour('오전 12:05')).toBe('00:05'); + }); + + it('오후 12시는 12시로 변환한다', () => { + expect(convertTo24Hour('오후 12:00')).toBe('12:00'); + }); + + it('이미 24시간 형식이면 그대로 반환한다', () => { + expect(convertTo24Hour('23:30')).toBe('23:30'); + }); + + it('한 자리 시간도 0 패딩하여 반환한다', () => { + expect(convertTo24Hour('9:05')).toBe('09:05'); + }); +}); + +describe('convertTo12Hour → convertTo24Hour 왕복 변환', () => { + const times = ['00:00', '00:30', '09:15', '12:00', '12:30', '23:59']; + + times.forEach((time) => { + it(`${time}은 12시간 변환 후 다시 24시간으로 복원된다`, () => { + expect(convertTo24Hour(convertTo12Hour(time))).toBe(time); + }); + }); +}); + +describe('getPastDate', () => { + it('1일 전 날짜를 반환한다', () => { + expect(getPastDate('2024-03-15', 1)).toBe('2024-03-14'); + }); + + it('월 경계를 넘어서 계산한다', () => { + expect(getPastDate('2024-03-01', 1)).toBe('2024-02-29'); // 2024년은 윤년 + }); + + it('연도 경계를 넘어서 계산한다', () => { + expect(getPastDate('2024-01-01', 1)).toBe('2023-12-31'); + }); + + it('0일 전이면 같은 날짜를 반환한다', () => { + expect(getPastDate('2024-06-15', 0)).toBe('2024-06-15'); + }); +}); diff --git a/frontend/src/lib/utils/useDebounce.test.ts b/frontend/src/lib/utils/useDebounce.test.ts new file mode 100644 index 00000000..00cad284 --- /dev/null +++ b/frontend/src/lib/utils/useDebounce.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('delay가 지나기 전에는 fn이 호출되지 않는다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('첫 번째 호출'); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('delay가 지나면 fn이 호출된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('인자값'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledWith('인자값'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('연속 호출 시 마지막 호출만 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('첫 번째'); + result.current.debounced('두 번째'); + result.current.debounced('세 번째'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('세 번째'); + }); + + it('cancel 호출 시 대기 중인 실행이 취소된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('호출'); + result.current.cancel(); + vi.advanceTimersByTime(500); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('unmount 시 타이머가 정리된다', () => { + const fn = vi.fn(); + const { result, unmount } = renderHook(() => useDebounce(fn, 500)); + + act(() => { + result.current.debounced('호출'); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/useThrottle.test.ts b/frontend/src/lib/utils/useThrottle.test.ts new file mode 100644 index 00000000..e7f7e45d --- /dev/null +++ b/frontend/src/lib/utils/useThrottle.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useThrottle } from './useThrottle'; + +describe('useThrottle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('첫 번째 호출은 즉시 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('첫 번째'); + }); + + it('delay 이내 연속 호출은 한 번만 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.throttled('세 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('delay 후 trailing edge에서 마지막 인자로 한 번 더 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.throttled('세 번째'); + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('세 번째'); + }); + + it('delay가 지난 후 다시 호출하면 즉시 실행된다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + vi.advanceTimersByTime(500); + result.current.throttled('두 번째'); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(2, '두 번째'); + }); + + it('flush 호출 시 대기 중인 실행을 즉시 처리한다', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, 500)); + + act(() => { + result.current.throttled('첫 번째'); + result.current.throttled('두 번째'); + result.current.flush(); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('두 번째'); + }); +}); diff --git a/frontend/src/store/useLocationPermissionStore.test.ts b/frontend/src/store/useLocationPermissionStore.test.ts new file mode 100644 index 00000000..6f640c83 --- /dev/null +++ b/frontend/src/store/useLocationPermissionStore.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useLocationPermissionStore } from './useLocationPermissionStore'; + +describe('canShowToast', () => { + beforeEach(() => { + useLocationPermissionStore.setState({ + permissionStatus: 'unknown', + hasAskedPermission: false, + lastToastShownAt: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('lastToastShownAt이 null이면 토스트를 표시할 수 있다', () => { + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); + + it('24시간이 지나지 않았으면 토스트를 표시하지 않는다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 23 * 60 * 60 * 1000, // 23시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(false); + }); + + it('정확히 24시간이 지났으면 토스트를 표시할 수 있다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 24 * 60 * 60 * 1000, // 정확히 24시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); + + it('24시간 이상 지났으면 토스트를 표시할 수 있다', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-15T12:00:00').getTime(); + vi.setSystemTime(now); + + useLocationPermissionStore.setState({ + lastToastShownAt: now - 25 * 60 * 60 * 1000, // 25시간 전 + }); + + const { canShowToast } = useLocationPermissionStore.getState(); + expect(canShowToast()).toBe(true); + }); +}); + +describe('setPermissionStatus', () => { + beforeEach(() => { + useLocationPermissionStore.setState({ permissionStatus: 'unknown' }); + }); + + it('권한 상태를 변경한다', () => { + useLocationPermissionStore.getState().setPermissionStatus('granted'); + expect(useLocationPermissionStore.getState().permissionStatus).toBe('granted'); + }); + + it('denied 상태로 변경한다', () => { + useLocationPermissionStore.getState().setPermissionStatus('denied'); + expect(useLocationPermissionStore.getState().permissionStatus).toBe('denied'); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c0b31617..b994515d 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -16,11 +16,22 @@ const dirname = export default defineConfig({ test: { projects: [ + { + test: { + name: 'unit', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + environment: 'jsdom', + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(dirname, 'src'), + }, + }, + }, { extends: true, plugins: [ - // The plugin will run tests for the stories defined in your Storybook config - // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest storybookTest({ configDir: path.join(dirname, '.storybook') }), ], test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90492712..6aaac463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,6 +385,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/google.maps': specifier: ^3.58.1 version: 3.58.1 @@ -415,6 +421,9 @@ importers: eslint-plugin-storybook: specifier: ^10.1.11 version: 10.1.11(eslint@9.39.1(jiti@2.6.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) msw: specifier: ^2.12.4 version: 2.12.4(@types/node@20.19.27)(typescript@5.9.3) @@ -441,7 +450,7 @@ importers: version: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) vitest: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) mobile-app: dependencies: @@ -529,6 +538,21 @@ packages: '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} peerDependencies: @@ -918,6 +942,10 @@ packages: '@borewit/text-codec@0.1.1': resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@capacitor/android@7.5.0': resolution: {integrity: sha512-VjESuJYzQQH4vxvrOQV0yXuDv3Gx1bfb6YpAkoGfUHe0kiw0LY5nh5Kn7I5bI9hlht9SJ8YmSsCgFeMNyzKoWQ==} peerDependencies: @@ -989,6 +1017,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -1202,6 +1266,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -3422,6 +3495,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -3762,6 +3850,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4396,6 +4485,9 @@ packages: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -4850,6 +4942,10 @@ packages: csp_evaluator@1.1.5: resolution: {integrity: sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4907,6 +5003,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -5175,6 +5275,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -5856,6 +5960,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -6116,6 +6224,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6414,6 +6525,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6692,6 +6812,10 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -6750,6 +6874,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -7203,6 +7330,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7785,6 +7915,10 @@ packages: resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -8214,6 +8348,9 @@ packages: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -8365,9 +8502,17 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -8617,6 +8762,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -8697,10 +8846,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -8816,6 +8967,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -8832,6 +8987,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} @@ -8864,6 +9023,14 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8991,6 +9158,10 @@ packages: resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} engines: {node: '>=8'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -9003,6 +9174,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -9168,6 +9342,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.41.0': dependencies: '@panva/hkdf': 1.2.1 @@ -9931,6 +10125,10 @@ snapshots: '@borewit/text-codec@0.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@capacitor/android@7.5.0(@capacitor/core@7.5.0)': dependencies: '@capacitor/core': 7.5.0 @@ -10014,6 +10212,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -10164,6 +10386,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@faker-js/faker@10.1.0': {} '@faker-js/faker@9.9.0': {} @@ -12472,7 +12698,7 @@ snapshots: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: - react - react-dom @@ -12747,6 +12973,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -13233,7 +13469,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -13249,7 +13485,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -13270,7 +13506,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) optionalDependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) transitivePeerDependencies: @@ -13909,6 +14145,10 @@ snapshots: basic-ftp@5.2.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} bin-version-check@5.1.0: @@ -14386,6 +14626,11 @@ snapshots: csp_evaluator@1.1.5: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -14432,6 +14677,13 @@ snapshots: data-uri-to-buffer@6.0.2: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -14658,6 +14910,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -15606,6 +15860,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -15854,6 +16114,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-reference@1.2.1: @@ -16405,6 +16667,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -16701,6 +16989,8 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -16753,6 +17043,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -17179,6 +17471,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} passport-google-oauth20@2.0.0: @@ -17803,6 +18099,10 @@ snapshots: sax@1.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@3.3.0: @@ -18372,6 +18672,8 @@ snapshots: symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -18539,8 +18841,16 @@ snapshots: dependencies: tldts: 7.0.19 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.19 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} triple-beam@1.4.1: {} @@ -18763,6 +19073,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.25.0: {} + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -18934,7 +19246,7 @@ snapshots: terser: 5.44.1 yaml: 2.8.2 - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) @@ -18960,6 +19272,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 20.19.27 '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -18973,6 +19286,10 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -18990,6 +19307,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -19083,6 +19402,16 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -19228,6 +19557,8 @@ snapshots: xdg-basedir@4.0.0: {} + xml-name-validator@5.0.0: {} + xml2js@0.6.2: dependencies: sax: 1.5.0 @@ -19237,6 +19568,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} xtend@4.0.2: {} From cc9b4a45176c19155c8c2e0d7ffc3cc321c41808 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 13:52:17 +0900 Subject: [PATCH 03/39] =?UTF-8?q?test:=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=94=BD=EC=8A=A4=EC=B2=98,=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.gitignore | 4 +- frontend/e2e/fixtures/api.ts | 139 ++++++++++++++++++++++++++++++++++ frontend/package.json | 11 ++- frontend/playwright.config.ts | 50 ++++++++++++ package.json | 2 + pnpm-lock.yaml | 75 ++++++++++-------- 6 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 frontend/e2e/fixtures/api.ts create mode 100644 frontend/playwright.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index e92bdf67..ec99434b 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -54,4 +54,6 @@ storybook-static .env.sentry-build-plugin # lighthouse -.lighthouseci/ \ No newline at end of file +.lighthouseci/e2e/.auth/ +playwright-report/ +test-results/ diff --git a/frontend/e2e/fixtures/api.ts b/frontend/e2e/fixtures/api.ts new file mode 100644 index 00000000..e1e12567 --- /dev/null +++ b/frontend/e2e/fixtures/api.ts @@ -0,0 +1,139 @@ +import { Page } from '@playwright/test'; + +const TODAY = new Date().toISOString().split('T')[0]; + +export interface CreatedRecord { + id: string; + title: string; +} + +export interface CreatedGroup { + id: string; + inviteCode: string; +} + +async function getAuthHeaders(page: Page): Promise> { + const cookies = await page.context().cookies(); + const token = cookies.find((c) => c.name === 'x-guest-access-token')?.value; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** + * 테스트용 기록을 실 백엔드에 생성하고 id를 반환한다. + * @param options.extraBlocks - 기본 DATE/TIME/TEXT 블록 뒤에 추가할 블록 (row 3+부터 지정) + * 태그 추가 예시: { type: 'tags', value: { tags: ['tag1'] }, layout: { row: 3, col: 1, span: 2 } } + */ +export async function createTestRecord( + page: Page, + title = 'E2E 테스트 기록', + date = TODAY, + options?: { + extraBlocks?: Array<{ type: string; value: object; layout: { row: number; col: number; span: number } }>; + }, +): Promise { + const headers = await getAuthHeaders(page); + const blocks = [ + { type: 'DATE', value: { date }, layout: { row: 1, col: 1, span: 1 } }, + { type: 'TIME', value: { time: '12:00' }, layout: { row: 1, col: 2, span: 1 } }, + { type: 'TEXT', value: { text: 'E2E 테스트 내용입니다.' }, layout: { row: 2, col: 1, span: 2 } }, + ...(options?.extraBlocks ?? []), + ]; + const res = await page.request.post('/api/posts', { + headers, + data: { title, blocks, scope: 'PERSONAL' }, + }); + + const body = await res.json(); + if (!body.success) throw new Error(`기록 생성 실패: ${JSON.stringify(body.error)}`); + return { id: body.data.id, title }; +} + +/** + * 테스트용 기록을 삭제한다. + */ +export async function deleteTestRecord( + page: Page, + recordId: string, +): Promise { + const headers = await getAuthHeaders(page); + await page.request.delete(`/api/posts/${recordId}`, { headers }); +} + +// 1×1 투명 PNG (68 bytes) +const MINIMAL_PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=='; + +/** + * 1×1 PNG를 MinIO에 업로드하고 mediaId를 반환한다. + * presign → PUT → complete 3단계 플로우. + */ +export async function uploadTestImage(page: Page): Promise { + const headers = await getAuthHeaders(page); + + const presignRes = await page.request.post('/api/media/presign', { + headers, + data: { files: [{ contentType: 'image/png', size: 68 }] }, + }); + const presignBody = await presignRes.json(); + if (!presignBody.success) throw new Error(`presign 실패: ${JSON.stringify(presignBody.error)}`); + + const { mediaId, uploadUrl } = presignBody.data.items[0] as { + mediaId: string; + uploadUrl: string; + }; + + const imageData = Buffer.from(MINIMAL_PNG_B64, 'base64'); + const uploadRes = await page.request.put(uploadUrl, { + data: imageData, + headers: { 'Content-Type': 'image/png' }, + }); + if (!uploadRes.ok()) throw new Error(`MinIO 업로드 실패: ${uploadRes.status()}`); + + const completeRes = await page.request.post('/api/media/complete', { + headers, + data: { mediaIds: [mediaId] }, + }); + const completeBody = await completeRes.json(); + if (!completeBody.success) throw new Error(`complete 실패: ${JSON.stringify(completeBody.error)}`); + + return mediaId; +} + +/** + * 테스트용 그룹을 생성하고 초대 코드를 반환한다. + */ +export async function createTestGroup( + page: Page, + name = 'E2E 테스트 그룹', +): Promise { + const headers = await getAuthHeaders(page); + + const groupRes = await page.request.post('/api/groups', { + headers, + data: { name }, + }); + const groupBody = await groupRes.json(); + if (!groupBody.success) throw new Error(`그룹 생성 실패: ${JSON.stringify(groupBody.error)}`); + + const groupId = groupBody.data.id; + + const inviteRes = await page.request.post(`/api/groups/${groupId}/invites`, { + headers, + data: { permission: 'EDITOR', expiresInSeconds: 86400 }, + }); + const inviteBody = await inviteRes.json(); + if (!inviteBody.success) throw new Error(`초대 코드 생성 실패: ${JSON.stringify(inviteBody.error)}`); + + return { id: groupId, inviteCode: inviteBody.data.code }; +} + +/** + * 테스트용 그룹을 삭제한다. + */ +export async function deleteTestGroup( + page: Page, + groupId: string, +): Promise { + const headers = await getAuthHeaders(page); + await page.request.delete(`/api/groups/${groupId}`, { headers }); +} diff --git a/frontend/package.json b/frontend/package.json index 0d25e6d0..8d25c19a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,13 @@ "lint": "eslint", "lint:fix": "eslint", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "test": "vitest run --project unit", + "test:e2e": "playwright test", + "test:e2e:view": "playwright test; playwright show-report", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "show-report": "playwright show-report" }, "dependencies": { "@capacitor/app": "^7.0.0", @@ -62,6 +68,7 @@ "@chromatic-com/storybook": "^4.1.3", "@faker-js/faker": "^10.1.0", "@next/bundle-analyzer": "^16.2.4", + "@playwright/test": "^1.60.0", "@storybook/addon-a11y": "^10.1.11", "@storybook/addon-docs": "^10.1.11", "@storybook/addon-onboarding": "^10.1.11", @@ -83,7 +90,7 @@ "jsdom": "^29.1.1", "msw": "^2.12.4", "msw-storybook-addon": "^2.0.6", - "playwright": "^1.57.0", + "playwright": "^1.60.0", "storybook": "^10.1.11", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..b04622f3 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // dev 서버는 RSC 컴파일 부하가 크므로 로컬에서도 워커 수를 제한 + workers: process.env.CI ? 1 : 2, + // dev 서버 RSC 렌더링 지연을 허용하기 위해 타임아웃 60초 + timeout: 60000, + reporter: [['html', { open: 'never' }]], + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'setup', + testMatch: /setup\/.*\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/guest.json', + }, + dependencies: ['setup'], + testIgnore: /share\.spec\.ts/, + }, + { + name: 'chromium-public', + use: { ...devices['Desktop Chrome'] }, + testMatch: /share\.spec\.ts/, + dependencies: ['setup'], + }, + ], + + // E2E 테스트는 개발 서버(포트 3000) + 백엔드가 실행 중인 상태에서 실행합니다. + // 사전 준비: pnpm infra:up && pnpm dev:be && pnpm dev:fe + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000/favicon.ico', + reuseExistingServer: true, + timeout: 120 * 1000, + }, +}); diff --git a/package.json b/package.json index 3586fd31..520be2d2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint:fix:all": "pnpm -r lint", "test:fe": "pnpm --filter frontend test", "test:fe:e2e": "pnpm --filter frontend test:e2e", + "test:fe:e2e:view": "pnpm --filter frontend test:e2e:view", + "show-report:fe": "pnpm --filter frontend show-report", "test:be": "pnpm --filter backend test", "test:be:e2e": "pnpm --filter backend test:e2e", "test:be:e2e:debug": "pnpm --filter backend test:e2e:debug", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6aaac463..dcb2f9e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,7 +260,7 @@ importers: version: 0.15.1 '@next/third-parties': specifier: ^16.2.1 - version: 16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -272,7 +272,7 @@ importers: version: 1.2.4(@types/react@19.2.7)(react@19.2.1) '@sentry/nextjs': specifier: ^10.38.0 - version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2)) + version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2)) '@tanstack/react-query': specifier: ^5.90.12 version: 5.90.12(react@19.2.1) @@ -308,10 +308,10 @@ importers: version: 0.561.0(react@19.2.1) next: specifier: 16.0.11 - version: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -367,6 +367,9 @@ importers: '@next/bundle-analyzer': specifier: ^16.2.4 version: 16.2.4 + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@storybook/addon-a11y': specifier: ^10.1.11 version: 10.1.11(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) @@ -381,7 +384,7 @@ importers: version: 10.1.11(@vitest/browser-playwright@4.0.16)(@vitest/browser@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16))(@vitest/runner@4.0.16)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vitest@4.0.16) '@storybook/nextjs-vite': specifier: ^10.1.11 - version: 10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) + version: 10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 @@ -405,7 +408,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitest/browser-playwright': specifier: ^4.0.16 - version: 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + version: 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/coverage-v8': specifier: ^4.0.16 version: 4.0.16(@vitest/browser@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16) @@ -431,8 +434,8 @@ importers: specifier: ^2.0.6 version: 2.0.6(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3)) playwright: - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 storybook: specifier: ^10.1.11 version: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -2377,6 +2380,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -7494,13 +7502,13 @@ packages: peerDependencies: sharp: '>= 0.30.6' - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -11412,9 +11420,9 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.11': optional: true - '@next/third-parties@16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@next/third-parties@16.2.1(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': dependencies: - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 third-party-capital: 1.0.20 @@ -11713,6 +11721,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': @@ -12170,7 +12182,7 @@ snapshots: '@sentry/utils': 7.120.4 localforage: 1.10.0 - '@sentry/nextjs@10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2))': + '@sentry/nextjs@10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0(esbuild@0.27.2))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 @@ -12183,7 +12195,7 @@ snapshots: '@sentry/react': 10.38.0(react@19.2.1) '@sentry/vercel-edge': 10.38.0 '@sentry/webpack-plugin': 4.9.0(webpack@5.103.0(esbuild@0.27.2)) - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) rollup: 4.55.1 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -12696,7 +12708,7 @@ snapshots: storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) optionalDependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) - '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: @@ -12733,18 +12745,18 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@storybook/nextjs-vite@10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2))': + '@storybook/nextjs-vite@10.1.11(@babel/core@7.28.5)(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2))': dependencies: '@storybook/builder-vite': 10.1.11(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) '@storybook/react': 10.1.11(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3) '@storybook/react-vite': 10.1.11(esbuild@0.27.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) - vite-plugin-storybook-nextjs: 3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) + vite-plugin-storybook-nextjs: 3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -13463,11 +13475,11 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16)': + '@vitest/browser-playwright@4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) - playwright: 1.57.0 + playwright: 1.60.0 tinyrainbow: 3.0.3 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: @@ -17232,10 +17244,10 @@ snapshots: netmask@2.0.2: {} - next-auth@5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1): + next-auth@5.0.0-beta.30(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1): dependencies: '@auth/core': 0.41.0 - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -17243,7 +17255,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 16.0.11 '@swc/helpers': 0.5.15 @@ -17262,6 +17274,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.11 '@next/swc-win32-x64-msvc': 16.0.11 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -17617,11 +17630,11 @@ snapshots: dependencies: sharp: 0.34.5 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -19204,13 +19217,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-storybook-nextjs@3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)): + vite-plugin-storybook-nextjs@3.1.8(next@16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.2.3 - next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.11(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) storybook: 10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) ts-dedent: 2.2.0 vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2) @@ -19271,7 +19284,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 20.19.27 - '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@20.19.27)(typescript@5.9.3))(playwright@1.60.0)(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16) jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti From 4c203b275853424f2ed269aff483985fb11c1be0 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 13:55:34 +0900 Subject: [PATCH 04/39] =?UTF-8?q?test:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EA=B7=B8=EB=A3=B9=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 게스트 가입 및 기존 세션 복원 흐름 검증 - 초대 코드 접근 UI, 게스트 사용자 참여 버튼, 미인증 사용자의 로그인 페이지 리디렉션 검증 --- frontend/e2e/group-invite.spec.ts | 73 +++++++++++++++++++++++++++++++ frontend/e2e/login.spec.ts | 53 ++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 frontend/e2e/group-invite.spec.ts create mode 100644 frontend/e2e/login.spec.ts diff --git a/frontend/e2e/group-invite.spec.ts b/frontend/e2e/group-invite.spec.ts new file mode 100644 index 00000000..bc5b8a49 --- /dev/null +++ b/frontend/e2e/group-invite.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestGroup, deleteTestGroup } from './fixtures/api'; + +test.describe('그룹 초대', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('초대 코드로 접근하면 그룹 초대 UI와 그룹 정보(이름, 인원수)가 표시된다', async ({ page }) => { + await page.goto(`/invite?inviteCode=${inviteCode}`); + await expect(page.getByRole('heading', { name: '그룹 초대' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('회원님을 초대했습니다')).toBeVisible({ timeout: 10000 }); + + // 그룹 이름 표시 확인 + await expect(page.getByText('E2E 테스트 그룹')).toBeVisible({ timeout: 10000 }); + + // 인원수 1명 표시 확인 + await expect(page.getByText(/1명/)).toBeVisible({ timeout: 5000 }); + }); + + test('게스트로 인증된 사용자에게는 게스트 계정으로 참여하기 버튼이 표시된다', async ({ page }) => { + await page.goto(`/invite?inviteCode=${inviteCode}`); + // auth 스토어 hydration 완료 대기 — guest 상태면 "게스트 계정으로 참여하기" + const joinButton = page.getByRole('button', { name: /게스트 계정으로 참여하기/i }); + await expect(joinButton).toBeVisible({ timeout: 5000 }); + }); + + test('미인증 사용자에게는 수락하기 버튼이 표시된다', async ({ browser }) => { + // 명시적 빈 storageState로 완전한 비인증 상태 보장 + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const anonPage = await ctx.newPage(); + try { + await anonPage.goto(`/invite?inviteCode=${inviteCode}`); + await expect(anonPage.getByRole('button', { name: '수락하기' })).toBeVisible({ timeout: 8000 }); + } finally { + await ctx.close(); + } + }); + + test('미인증 사용자가 수락하기 버튼을 클릭하면 로그인 페이지로 이동한다', async ({ browser }) => { + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const anonPage = await ctx.newPage(); + try { + await anonPage.goto(`/invite?inviteCode=${inviteCode}`); + await expect(anonPage.getByRole('button', { name: '수락하기' })).toBeVisible({ timeout: 8000 }); + await anonPage.getByRole('button', { name: '수락하기' }).click(); + await expect(anonPage).toHaveURL(/\/login/, { timeout: 15000 }); + } finally { + await ctx.close(); + } + }); + + test('초대 코드 없이 접근하면 그룹 초대 UI가 표시된다', async ({ page }) => { + await page.goto('/invite'); + await expect(page.getByRole('heading', { name: '그룹 초대' })).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 00000000..1c4b189b --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; + +test.describe('로그인', () => { + test('처음 방문한 사용자가 가입 없이 시작하기를 클릭하면 toast가 표시되고 홈으로 이동한다', async ({ + browser, + }) => { + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const anonPage = await ctx.newPage(); + try { + await anonPage.goto('/login'); + + // 새 게스트 세션 생성 API 호출 확인 (이 경로일 때 toast가 표시됨) + // window.location.href로 full reload가 일어나 toast를 직접 잡기 어려우므로 + // POST /api/auth/guest 호출 여부로 "toast가 뜨는 코드 경로"임을 검증 + await Promise.all([ + anonPage.waitForResponse( + (res) => + res.url().includes('/api/auth/guest') && + !res.url().includes('/restore') && + res.request().method() === 'POST', + { timeout: 15000 }, + ), + anonPage.getByRole('button', { name: '가입 없이 시작하기' }).click(), + ]); + + // 홈으로 이동 + await anonPage.waitForURL('/', { timeout: 15000 }); + } finally { + await ctx.close(); + } + }); + + test('이미 유효한 게스트 계정으로 가입 없이 시작하기를 클릭하면 toast 없이 홈으로 이동한다', async ({ + page, + }) => { + await page.goto('/login'); + + await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes('/api/auth/guest/restore') && res.request().method() === 'POST', + { timeout: 15000 }, + ), + page.getByRole('button', { name: '가입 없이 시작하기' }).click(), + ]); + + // 홈으로 이동 + await page.waitForURL('/', { timeout: 15000 }); + + // 기존 세션 복원이므로 만료 안내 toast가 없어야 함 + await expect(page.getByText(/게스트 모드는 3일/)).not.toBeVisible(); + }); +}); From 90b12cc879ec5eab5596a938a1580cc4c8cd07fd Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 13:57:07 +0900 Subject: [PATCH 05/39] =?UTF-8?q?test:=20=EA=B8=B0=EB=A1=9D=ED=95=A8=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=83=90=EC=83=89=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주간 달력 월 클릭 이동 - 정렬 drawer UI - 최신순, 오래된순, 기록 많은순 실제 정렬 순서 검증 - 날짜 선택 시 타임라인 페이지로 이동 - 전월 이동 후 월 전체보기, 년도 전체보기 검증 --- frontend/e2e/date-selector-drawer.spec.ts | 94 ++++++++++++++++++ frontend/e2e/my-month.spec.ts | 113 ++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 frontend/e2e/date-selector-drawer.spec.ts create mode 100644 frontend/e2e/my-month.spec.ts diff --git a/frontend/e2e/date-selector-drawer.spec.ts b/frontend/e2e/date-selector-drawer.spec.ts new file mode 100644 index 00000000..f1a8e564 --- /dev/null +++ b/frontend/e2e/date-selector-drawer.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date(); +const CURRENT_YEAR = TODAY.getFullYear(); +const CURRENT_YEAR_MONTH = `${CURRENT_YEAR}-${String(TODAY.getMonth() + 1).padStart(2, '0')}`; +const TODAY_STR = `${CURRENT_YEAR_MONTH}-${String(TODAY.getDate()).padStart(2, '0')}`; +const TODAY_DAY = TODAY.getDate(); + +const PREV_MONTH = new Date(CURRENT_YEAR, TODAY.getMonth() - 1, 1); +const PREV_YEAR_MONTH = `${PREV_MONTH.getFullYear()}-${String(PREV_MONTH.getMonth() + 1).padStart(2, '0')}`; + +const RECORD_TITLE = 'E2E 날짜 찾기 테스트 기록'; + +async function openDateDrawer(page: Page) { + await page.goto('/my'); + // "내 기록함" heading과 같은 flex-between 행에 있는 달력 버튼 + const calendarBtn = page + .locator('.flex.items-center.justify-between') + .filter({ has: page.getByRole('heading', { name: '내 기록함' }) }) + .getByRole('button'); + await expect(calendarBtn).toBeVisible({ timeout: 8000 }); + await calendarBtn.click(); + await expect(page.getByText('날짜로 찾기')).toBeVisible({ timeout: 5000 }); +} + +test.describe('내 기록함 날짜 찾기 drawer', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, RECORD_TITLE, TODAY_STR); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + if (!postId) return; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('날짜를 선택하면 해당 날짜의 기록을 타임라인으로 확인할 수 있다', async ({ page }) => { + await openDateDrawer(page); + + // 달력에서 오늘 날짜 버튼 클릭 — drawer 내 dialog 영역으로 스코프 + // "전월"/"후월" 레이블 span과 구분하기 위해 exact: true 사용 + await page + .locator('[role="dialog"]') + .getByRole('button', { name: String(TODAY_DAY), exact: true }) + .click(); + + // 해당 날짜 타임라인 페이지로 이동 + await expect(page).toHaveURL(new RegExp(`/my/detail/${TODAY_STR}`), { timeout: 10000 }); + + // 오늘 생성한 기록 타이틀 확인 (타임라인에서 같은 타이틀이 여러 요소로 렌더링될 수 있어 first() 사용) + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 8000 }); + }); + + test('전월로 이동 후 월 기록 전체보기를 클릭하면 해당 월의 기록함으로 이동한다', async ({ + page, + }) => { + await openDateDrawer(page); + + // 전월 버튼: "전월" span의 직접 부모 div 안의 버튼 + // div.filter({has: span}) 은 모든 조상 div에 매칭되므로 xpath=.. 으로 span의 부모만 타겟 + await page + .locator('span') + .filter({ hasText: /^전월$/ }) + .locator('xpath=..') + .getByRole('button') + .click(); + + // 헤더 월 레이블이 전월로 변경됐는지 확인 + await expect( + page.getByText(`${PREV_MONTH.getFullYear()}년 ${PREV_MONTH.getMonth() + 1}월`), + ).toBeVisible({ timeout: 3000 }); + + // 월 기록 전체보기 클릭 + await page.getByText('월 기록 전체보기').click(); + await expect(page).toHaveURL(new RegExp(`/my/month/${PREV_YEAR_MONTH}`), { timeout: 10000 }); + }); + + test('년도 기록 전체보기를 클릭하면 해당 년도의 기록함으로 이동한다', async ({ page }) => { + await openDateDrawer(page); + + await page.getByText(`${CURRENT_YEAR}년 기록 전체보기`).click(); + await expect(page).toHaveURL(new RegExp(`/my/year/${CURRENT_YEAR}`), { timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/my-month.spec.ts b/frontend/e2e/my-month.spec.ts new file mode 100644 index 00000000..1a4ebcda --- /dev/null +++ b/frontend/e2e/my-month.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date(); +const CURRENT_YEAR_MONTH = `${TODAY.getFullYear()}-${String(TODAY.getMonth() + 1).padStart(2, '0')}`; +const DISPLAY_YEAR_MONTH = `${TODAY.getFullYear()}.${String(TODAY.getMonth() + 1).padStart(2, '0')}`; + +// 정렬 검증을 위해 서로 다른 날짜의 기록이 필요: +// - 이번 달 1일: 기록 1개 (가장 오래된 날, 기록 적음) +// - 오늘: 기록 3개 (가장 최신 날, 기록 많음) +// date-desc → 오늘 먼저, date-asc → 1일 먼저, count-desc → 오늘 먼저(기록 3개) +// → date-asc 와 count-desc 가 서로 다른 카드를 가리켜 정렬 로직을 독립적으로 검증 +const FIRST_OF_MONTH = `${CURRENT_YEAR_MONTH}-01`; +const TODAY_STR = `${CURRENT_YEAR_MONTH}-${String(TODAY.getDate()).padStart(2, '0')}`; +const TODAY_DAY_BADGE = String(TODAY.getDate()).padStart(2, '0'); // date.split('-')[2] 형식 + +// 날짜 뱃지에서 첫 번째 카드의 일자(두 자리 문자열)를 읽는 헬퍼 +const getFirstCardDay = (page: import('@playwright/test').Page) => + page.locator('.absolute.top-3.left-3.z-10').first().locator('span').first(); + +test.describe('주간 달력과 월별 기록함', () => { + const postIds: string[] = []; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + + // 이번 달 1일에 기록 1개 생성 (date-asc 에서 첫 번째) + const firstRecord = await createTestRecord(page, 'E2E 1일 기록', FIRST_OF_MONTH); + postIds.push(firstRecord.id); + + // 오늘 기록 3개 생성 (date-desc·count-desc 에서 첫 번째) + for (let i = 1; i <= 3; i++) { + const r = await createTestRecord(page, `E2E 오늘 기록 ${i}`, TODAY_STR); + postIds.push(r.id); + } + + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + for (const id of postIds) { + await deleteTestRecord(page, id); + } + await ctx.close(); + }); + + test('주간 달력의 월을 클릭하면 해당 월의 기록함으로 이동한다', async ({ page }) => { + await page.goto('/'); + + // WeekCalendar 월 레이블 (예: "2026.05") 클릭 + const monthLabel = page.getByText(DISPLAY_YEAR_MONTH).first(); + await expect(monthLabel).toBeVisible({ timeout: 8000 }); + await monthLabel.click(); + + await expect(page).toHaveURL(new RegExp(`/my/month/${CURRENT_YEAR_MONTH}`), { timeout: 10000 }); + }); + + test('월별 기록함에서 정렬 아이콘을 클릭하면 정렬 drawer가 열리고 최신순·오래된순·기록 많은순을 선택할 수 있다', async ({ + page, + }) => { + await page.goto(`/my/month/${CURRENT_YEAR_MONTH}`); + + // 헤더 우측 정렬(ListFilter) 버튼 — Back 버튼 다음에 위치하므로 header 내 마지막 버튼 + const sortButton = page.locator('header').getByRole('button').last(); + await expect(sortButton).toBeVisible({ timeout: 8000 }); + await sortButton.click(); + + // 정렬 drawer 표시 확인 + await expect(page.getByText('기록 정렬')).toBeVisible({ timeout: 5000 }); + + // 세 가지 정렬 옵션 표시 확인 + await expect(page.getByText('최신순')).toBeVisible(); + await expect(page.getByText('오래된순')).toBeVisible(); + await expect(page.getByText('기록 많은순')).toBeVisible(); + + // 옵션 선택 시 URL에 sort 쿼리 반영 — 오래된순 선택 + await page.getByRole('button', { name: '오래된순' }).click(); + await expect(page).toHaveURL(/sort=date-asc/, { timeout: 5000 }); + + // drawer가 닫혔는지 확인 + await expect(page.getByText('기록 정렬')).not.toBeVisible(); + + // 기록 많은순 선택으로 전환 + await sortButton.click(); + await expect(page.getByText('기록 정렬')).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: '기록 많은순' }).click(); + await expect(page).toHaveURL(/sort=count-desc/, { timeout: 5000 }); + }); + + test('정렬 옵션에 따라 기록이 올바른 순서로 표시된다', async ({ page }) => { + const waitForCards = () => + expect(getFirstCardDay(page)).toBeVisible({ timeout: 8000 }); + + // 최신순(date-desc, 기본값): 오늘(기록 3개)이 첫 번째 + await page.goto(`/my/month/${CURRENT_YEAR_MONTH}`); + await waitForCards(); + await expect(getFirstCardDay(page)).toHaveText(TODAY_DAY_BADGE); + + // 오래된순(date-asc): 1일(기록 1개)이 첫 번째 — date-desc 와 다른 카드 + await page.goto(`/my/month/${CURRENT_YEAR_MONTH}?sort=date-asc`); + await waitForCards(); + await expect(getFirstCardDay(page)).toHaveText('01'); + + // 기록 많은순(count-desc): 오늘(기록 3개)이 첫 번째 — date-asc 와 다른 카드 + await page.goto(`/my/month/${CURRENT_YEAR_MONTH}?sort=count-desc`); + await waitForCards(); + await expect(getFirstCardDay(page)).toHaveText(TODAY_DAY_BADGE); + }); +}); From c6f415348af0dbd69470223d558d3520dc7f3509 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 13:58:04 +0900 Subject: [PATCH 06/39] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그, 감정, 날짜, 위치 단일 필터 및 종합 필터 검색 검증 - 검새 결과 클릭시 기록 상세 이동 검증 --- frontend/e2e/search-filters.spec.ts | 205 ++++++++++++++++++++++++++++ frontend/e2e/search.spec.ts | 64 +++++++++ 2 files changed, 269 insertions(+) create mode 100644 frontend/e2e/search-filters.spec.ts create mode 100644 frontend/e2e/search.spec.ts diff --git a/frontend/e2e/search-filters.spec.ts b/frontend/e2e/search-filters.spec.ts new file mode 100644 index 00000000..ec698a7a --- /dev/null +++ b/frontend/e2e/search-filters.spec.ts @@ -0,0 +1,205 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date(); +const TODAY_STR = `${TODAY.getFullYear()}-${String(TODAY.getMonth() + 1).padStart(2, '0')}-${String(TODAY.getDate()).padStart(2, '0')}`; +const FIRST_OF_MONTH = `${TODAY.getFullYear()}-${String(TODAY.getMonth() + 1).padStart(2, '0')}-01`; + +const YESTERDAY = new Date(TODAY); +YESTERDAY.setDate(TODAY.getDate() - 1); +const YESTERDAY_STR = `${YESTERDAY.getFullYear()}-${String(YESTERDAY.getMonth() + 1).padStart(2, '0')}-${String(YESTERDAY.getDate()).padStart(2, '0')}`; + +const RECORD_TITLE = 'E2E 필터 검색 기록'; // 모든 필터 조건 포함 +const CONTROL_TITLE = 'E2E 필터 대조 기록'; // 태그·감정·위치 없음 (오늘) +const YESTERDAY_TITLE = 'E2E 날짜 대조 기록'; // 어제 날짜 (날짜 필터 제외용) + +const TAG = 'E2E필터태그'; +const MOOD = '행복'; +const PLACE_NAME = '서울시청'; +const LAT = 37.5665; +const LNG = 126.978; + +// POST /api/search 응답을 기다리는 헬퍼 +const waitForSearch = (page: import('@playwright/test').Page) => + page.waitForResponse( + (res) => res.url().includes('/api/search') && res.request().method() === 'POST', + { timeout: 10000 }, + ); + +// 검색 페이지로 이동하고 Fast Refresh(dev 컴파일) 완료까지 대기 +async function gotoSearch(page: import('@playwright/test').Page) { + await page.goto('/search'); + // Fast Refresh가 진행 중이면 네트워크 요청이 끝날 때까지 대기 + await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); +} + +test.describe('검색 필터', () => { + const postIds: string[] = []; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + + // MAIN: 태그+감정+위치 모두 포함, 오늘 + const main = await createTestRecord(page, RECORD_TITLE, TODAY_STR, { + extraBlocks: [ + { type: 'TAG', value: { tags: [TAG] }, layout: { row: 3, col: 1, span: 2 } }, + { type: 'MOOD', value: { mood: MOOD }, layout: { row: 4, col: 1, span: 2 } }, + { + type: 'LOCATION', + value: { lat: LAT, lng: LNG, address: '서울특별시 중구 세종대로 110', placeName: PLACE_NAME }, + layout: { row: 5, col: 1, span: 2 }, + }, + ], + }); + postIds.push(main.id); + + // CONTROL: 태그·감정·위치 없음, 오늘 (필터 제외 검증용) + const control = await createTestRecord(page, CONTROL_TITLE, TODAY_STR); + postIds.push(control.id); + + // YESTERDAY: 어제 날짜 (날짜 필터 제외 검증용, 오늘이 1일이면 같은 날짜가 되지만 허용) + const yesterday = await createTestRecord(page, YESTERDAY_TITLE, YESTERDAY_STR); + postIds.push(yesterday.id); + + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + for (const id of postIds) { + await deleteTestRecord(page, id); + } + await ctx.close(); + }); + + test('태그로 검색하면 해당 태그를 가진 기록만 찾을 수 있다', async ({ page }) => { + await gotoSearch(page); + + const tagChip = page.getByRole('button', { name: '태그' }); + await expect(tagChip).toBeVisible({ timeout: 10000 }); + await tagChip.click(); + + const tagInput = page.getByPlaceholder('태그를 입력하세요'); + await expect(tagInput).toBeVisible({ timeout: 5000 }); + await tagInput.fill(TAG); + await page.keyboard.press('Enter'); + + await page.getByText('완료').click(); + await expect(page).toHaveURL(/tags=/, { timeout: 8000 }); + + // MAIN: 해당 태그 포함 → 결과에 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 태그 없음 → 결과에서 제외 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('감정으로 검색하면 해당 감정을 가진 기록만 찾을 수 있다', async ({ page }) => { + await gotoSearch(page); + + const emotionChip = page.getByRole('button', { name: '감정' }); + await expect(emotionChip).toBeVisible({ timeout: 10000 }); + await emotionChip.click(); + + await expect(page.locator('[role="dialog"]').getByText(MOOD)).toBeVisible({ timeout: 5000 }); + await page.locator('[role="dialog"]').getByText(MOOD).click(); + + await page.getByText('확인').click(); + await expect(page).toHaveURL(/emotions=/, { timeout: 8000 }); + + // MAIN: 해당 감정 포함 → 결과에 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 감정 없음 → 결과에서 제외 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('날짜로 오늘을 선택하면 오늘 추가한 기록을 확인할 수 있다', async ({ page }) => { + await gotoSearch(page); + + const dateChip = page.getByRole('button', { name: '날짜' }); + await expect(dateChip).toBeVisible({ timeout: 10000 }); + await dateChip.click(); + + // 오늘 날짜 한 번 클릭 → start=today, end=null (단일 날짜 검색) + await expect(page.getByText('이 달 전체 선택')).toBeVisible({ timeout: 5000 }); + const todayBtn = page.getByRole('button', { name: String(TODAY.getDate()), exact: true }).first(); + await todayBtn.click(); + + await page.getByText('완료').click(); + await expect(page).toHaveURL(new RegExp(`start=${TODAY_STR}`), { timeout: 8000 }); + + // MAIN·CONTROL: 오늘 날짜 → 결과에 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + // YESTERDAY: 어제 날짜 → 결과에서 제외 (오늘이 1일이면 같은 달이지만 날짜 다름) + if (TODAY.getDate() > 1) { + await expect(page.getByText(YESTERDAY_TITLE)).not.toBeVisible(); + } + }); + + test('이달 전체 선택 시 미래 날짜 없이 이달 기록을 확인할 수 있다', async ({ page }) => { + await gotoSearch(page); + + const dateChip = page.getByRole('button', { name: '날짜' }); + await expect(dateChip).toBeVisible({ timeout: 10000 }); + await dateChip.click(); + + await expect(page.getByText('이 달 전체 선택')).toBeVisible({ timeout: 5000 }); + await page.getByText('이 달 전체 선택').click(); + + await page.getByText('완료').click(); + + // start = 이달 1일, end = 오늘 (미래 날짜 미포함 검증) + await expect(page).toHaveURL(new RegExp(`start=${FIRST_OF_MONTH}`), { timeout: 8000 }); + await expect(page).toHaveURL(new RegExp(`end=${TODAY_STR}`)); + + // MAIN·CONTROL: 오늘(이달) → 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(CONTROL_TITLE).first()).toBeVisible({ timeout: 5000 }); + }); + + test('위치로 검색하면 해당 장소를 가진 기록만 찾을 수 있다', async ({ page }) => { + // 위치 필터는 지도 페이지로 이동하므로 URL 직접 조작 + await page.goto( + `/search?lat=${LAT}&lng=${LNG}&address=${encodeURIComponent(PLACE_NAME)}&radius=10`, + ); + + // MAIN: 해당 위치 포함 → 결과에 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 위치 없음 → 결과에서 제외 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('태그·감정·날짜·위치를 모두 적용해 종합 검색하면 조건을 모두 만족하는 기록만 찾을 수 있다', async ({ + page, + }) => { + await page.goto( + `/search?tags=${encodeURIComponent(TAG)}` + + `&emotions=${encodeURIComponent(MOOD)}` + + `&start=${TODAY_STR}` + + `&lat=${LAT}&lng=${LNG}&address=${encodeURIComponent(PLACE_NAME)}&radius=10`, + ); + + // MAIN: 모든 조건 만족 → 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL·YESTERDAY: 조건 미충족 → 제외 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + await expect(page.getByText(YESTERDAY_TITLE)).not.toBeVisible(); + }); + + test('input으로 제목 검색 시 제목이 일치하는 기록을 확인할 수 있다', async ({ page }) => { + await gotoSearch(page); + + const input = page.getByPlaceholder('제목이나 내용으로 검색'); + await expect(input).toBeVisible({ timeout: 5000 }); + + await Promise.all([waitForSearch(page), input.fill('E2E 필터 검색')]); + + // MAIN: '필터 검색' 제목 일치 → 포함 + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 8000 }); + // CONTROL·YESTERDAY: 제목 불일치 → 제외 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + await expect(page.getByText(YESTERDAY_TITLE)).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts new file mode 100644 index 00000000..fb6bef4c --- /dev/null +++ b/frontend/e2e/search.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +test.describe('검색', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + // 검색어 "E2E검색"으로 찾을 수 있도록 제목에 포함 + const record = await createTestRecord(page, 'E2E검색 테스트 기록'); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('검색 페이지에 진입하면 검색 입력창이 표시된다', async ({ page }) => { + await page.goto('/search'); + await expect(page.locator('input[type="search"], input[placeholder*="검색"]')).toBeVisible(); + }); + + test('검색어를 입력하면 결과 또는 빈 상태가 표시된다', async ({ page }) => { + await page.goto('/search'); + const input = page.locator('input').first(); + await input.fill('여행'); + await page.waitForTimeout(1500); // debounce 대기 + await expect(page.locator('body')).toBeVisible(); + }); + + test('검색 결과 기록을 클릭하면 기록 상세로 이동한다', async ({ page }) => { + await page.goto('/search'); + const input = page.locator('input').first(); + + // debounce + 검색 API 응답을 기다린 뒤 결과 확인 + await Promise.all([ + page.waitForResponse( + (res) => res.url().includes('/api/search') && res.request().method() === 'POST', + { timeout: 10000 }, + ), + input.fill('E2E검색'), + ]); + + // SearchItem → Date: Tue, 19 May 2026 14:06:07 +0900 Subject: [PATCH 09/39] =?UTF-8?q?chore:=20setup=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=9D=EC=84=B1=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=ED=86=A0=ED=81=B0=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?gitignore=EC=97=90=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/.gitignore b/frontend/.gitignore index ec99434b..05d766ca 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -57,3 +57,6 @@ storybook-static .lighthouseci/e2e/.auth/ playwright-report/ test-results/ + +# playwright auth state (setup 단계에서 생성되는 세션 토큰) +e2e/.auth/ From a987e7b75d237e3aa59bb3b79672231e16bb3f53 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 14:07:37 +0900 Subject: [PATCH 10/39] =?UTF-8?q?test:=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=85=8B=EC=97=85=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=98=A4=EB=B8=8C=EC=A0=9D=ED=8A=B8=20=EC=B6=94=EA=B0=80(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 실행 전 게스트 인증 상태 생성 - 기록 작성 및 수정 UI를 조작하는 페이지 오브젝트 --- frontend/e2e/pages/RecordEditorPage.ts | 52 ++++++++++++++++++++++++ frontend/e2e/setup/auth.setup.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 frontend/e2e/pages/RecordEditorPage.ts create mode 100644 frontend/e2e/setup/auth.setup.ts diff --git a/frontend/e2e/pages/RecordEditorPage.ts b/frontend/e2e/pages/RecordEditorPage.ts new file mode 100644 index 00000000..63ca5816 --- /dev/null +++ b/frontend/e2e/pages/RecordEditorPage.ts @@ -0,0 +1,52 @@ +import { Page, Locator } from '@playwright/test'; + +export class RecordEditorPage { + readonly page: Page; + readonly titleInput: Locator; + readonly saveButton: Locator; + readonly backButton: Locator; + + constructor(page: Page) { + this.page = page; + this.titleInput = page.getByPlaceholder('제목을 입력하세요'); + this.saveButton = page.getByRole('button', { name: '저장' }); + this.backButton = page.getByRole('button', { name: '뒤로' }); + } + + async goto(options: { mode?: 'add' | 'edit'; postId?: string; groupId?: string; date?: string } = {}) { + const params = new URLSearchParams(); + params.set('mode', options.mode ?? 'add'); + if (options.postId) params.set('postId', options.postId); + if (options.groupId) params.set('groupId', options.groupId); + if (options.date) params.set('date', options.date); + await this.page.goto(`/add?${params.toString()}`); + } + + async fillTitle(title: string) { + await this.titleInput.fill(title); + } + + async save() { + await this.saveButton.click(); + } + + async dragBlock(sourceSelector: string, targetSelector: string) { + const source = this.page.locator(sourceSelector); + const target = this.page.locator(targetSelector); + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) return; + + const sx = sourceBox.x + sourceBox.width / 2; + const sy = sourceBox.y + sourceBox.height / 2; + const tx = targetBox.x + targetBox.width / 2; + const ty = targetBox.y + targetBox.height / 2; + + // 300ms 롱프레스 후 드래그 (커스텀 pointer 이벤트 기반 DnD) + await this.page.mouse.move(sx, sy); + await this.page.mouse.down(); + await this.page.waitForTimeout(350); + await this.page.mouse.move(tx, ty, { steps: 15 }); + await this.page.mouse.up(); + } +} diff --git a/frontend/e2e/setup/auth.setup.ts b/frontend/e2e/setup/auth.setup.ts new file mode 100644 index 00000000..943f5b15 --- /dev/null +++ b/frontend/e2e/setup/auth.setup.ts @@ -0,0 +1,56 @@ +import { test as setup, expect, request as playwrightRequest } from '@playwright/test'; + +const GUEST_STATE_PATH = 'e2e/.auth/guest.json'; + +setup('게스트 인증 상태 설정', async ({ page }) => { + setup.setTimeout(60000); // 백엔드 cold start 고려 + + // page.request 대신 독립 request context 사용 — 페이지 로드 실패에 영향받지 않음 + const apiContext = await playwrightRequest.newContext({ + baseURL: 'http://localhost:3000', + }); + + const response = await apiContext.post('/api/auth/guest'); + const responseText = await response.text(); + console.log('auth/guest status:', response.status(), 'body:', responseText.slice(0, 300)); + expect([200, 201]).toContain(response.status()); + + const body = await response.json(); + const authHeader = response.headers()['authorization'] ?? ''; + const guestAccessToken = authHeader.replace('Bearer ', ''); + const guestSessionId = body?.data?.guestSessionId ?? ''; + + expect(guestSessionId).toBeTruthy(); + expect(guestAccessToken).toBeTruthy(); + + await apiContext.dispose(); + + // 쿠키를 먼저 설정한 뒤 페이지 로드 + await page.context().addCookies([ + { name: 'x-guest-session-id', value: guestSessionId, domain: 'localhost', path: '/' }, + { name: 'x-guest-access-token', value: guestAccessToken, domain: 'localhost', path: '/' }, + ]); + + // /login은 PUBLIC_PATHS라 인증 없이 빠르게 로드됨 → localStorage 주입 용도 + await page.goto('/login'); + + await page.evaluate( + ({ sessionId, token }) => { + const authState = { + state: { + userType: 'guest', + userId: null, + guestSessionId: sessionId, + guestAccessToken: token, + guestSessionExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + isLoggedIn: true, + }, + version: 0, + }; + localStorage.setItem('auth-storage', JSON.stringify(authState)); + }, + { sessionId: guestSessionId, token: guestAccessToken }, + ); + + await page.context().storageState({ path: GUEST_STATE_PATH }); +}); From a6b4ade804e29b49d4faa01aea7428380dfb0204 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Tue, 19 May 2026 14:08:41 +0900 Subject: [PATCH 11/39] =?UTF-8?q?test:=20=ED=99=88,=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=EC=82=AC=ED=95=AD,=20=ED=94=84=EB=A1=9C=ED=95=84,=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=ED=95=98=EA=B8=B0=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 화면 로드, 기록 카드 클릭시 상세 페이지 이동 - 공지사항 페이지 로드 및 빈 상태 안내 메시지 - 프로필 페이지 로드 및 수정 페이지 이동, 닉네임 저장 토스트 - 문의 유형 선택, 제출 버튼 활성화, 유효성 검사 --- frontend/e2e/announcements.spec.ts | 38 +++++++++++++++++++++ frontend/e2e/home.spec.ts | 53 ++++++++++++++++++++++++++++++ frontend/e2e/inquiry.spec.ts | 48 +++++++++++++++++++++++++++ frontend/e2e/profile.spec.ts | 48 +++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 frontend/e2e/announcements.spec.ts create mode 100644 frontend/e2e/home.spec.ts create mode 100644 frontend/e2e/inquiry.spec.ts create mode 100644 frontend/e2e/profile.spec.ts diff --git a/frontend/e2e/announcements.spec.ts b/frontend/e2e/announcements.spec.ts new file mode 100644 index 00000000..c618b281 --- /dev/null +++ b/frontend/e2e/announcements.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +// 공지사항 페이지는 서버 컴포넌트(RSC)로 백엔드에서 직접 fetch. +// 실서버가 실행된 상태에서 테스트 — 공지사항이 없으면 안내 메시지를 보여준다. +test.describe('공지사항', () => { + test('공지사항 페이지가 로드된다', async ({ page }) => { + await page.goto('/announcements'); + await expect(page.locator('body')).toBeVisible(); + await expect(page).toHaveURL('/announcements'); + }); + + test('헤더에 "공지사항" 제목이 표시된다', async ({ page }) => { + await page.goto('/announcements'); + await expect(page.getByRole('heading', { name: '공지사항' })).toBeVisible(); + }); + + test('공지사항이 없을 때 안내 메시지가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + const isEmpty = await page.getByText('공지사항이 없습니다.').isVisible({ timeout: 3000 }).catch(() => false); + const hasAnnouncements = await page.locator('main > div').count() > 0; + + // 공지사항이 없거나 있거나 - 둘 중 하나의 상태여야 한다 + expect(isEmpty || hasAnnouncements).toBe(true); + }); + + test('뒤로가기 버튼을 클릭하면 프로필 페이지로 이동한다', async ({ page }) => { + await page.goto('/profile'); + await page.goto('/announcements'); + + // Back 컴포넌트는 이전 페이지 또는 fallback(/profile)으로 이동 + const backButton = page.locator('button').first(); + await backButton.click(); + + // /profile로 이동하거나 브라우저 히스토리의 이전 페이지로 이동 + await expect(page).toHaveURL(/\/(profile|announcements)/, { timeout: 3000 }); + }); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 00000000..d994c91a --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +const TODAY = new Date().toISOString().split('T')[0]; + +test.describe('홈 → 기록 상세 이동', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, 'E2E 홈 테스트 기록'); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('홈 화면이 로드된다', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('body')).toBeVisible(); + await expect(page).toHaveURL(/localhost:3000\/?$/); + }); + + test('날짜 상세 페이지에서 기록 카드를 클릭하면 기록 상세로 이동한다', async ({ page }) => { + await page.goto(`/my/detail/${TODAY}`); + + // DailyDetailRecordItem은 div[onClick]으로 렌더링됨 — 제목으로 찾기 + const recordCard = page.locator('div[class*="cursor-pointer"]').filter({ hasText: 'E2E 홈 테스트 기록' }).first(); + await expect(recordCard).toBeVisible({ timeout: 8000 }); + await recordCard.click(); + + await expect(page).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 }); + }); +}); + +test.describe('홈 → 개인 기록(/my) 이동', () => { + test('하단 내 기록 탭을 클릭하면 /my로 이동한다', async ({ page }) => { + await page.goto('/'); + + //
+ + ), + ], parameters: { docs: { - description: { - story: '그룹 기록함 - 월별 기록 카드 그리드', - }, + description: { story: '그룹 기록함 — 그룹 월별 기록 카드 그리드' }, }, }, }; -// 기록이 없는 경우 export const EmptyRecords: Story = { - args: { - monthRecords: [], - cardRoute: '/my/month', - }, - parameters: { - docs: { - description: { - story: '기록이 없는 경우 - 아이콘과 버튼이 포함된 빈 상태 UI 표시', - }, - }, - }, -}; - -// 커버 이미지가 없는 기록들 -export const NoCoverImages: Story = { - args: { - monthRecords: mockMonthRecords.map((r) => ({ ...r, coverUrl: null })), - cardRoute: '/my/month', - }, - parameters: { - docs: { - description: { - story: '커버 이미지가 없는 기록들 - 기본 배경 표시', - }, - }, - }, -}; - -// 다크 모드 -export const DarkMode: Story = { - args: { - monthRecords: mockMonthRecords, - cardRoute: '/my/month', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, + args: { cardRoute: '/my/month' }, decorators: [ (Story) => ( -
-
- + +
+ 로딩 중...
}> + +
-
+ ), ], + parameters: { + docs: { + description: { story: '기록이 없는 경우 — 빈 상태 UI(아이콘 + 기록 추가하기 버튼) 표시' }, + }, + }, }; + diff --git a/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx b/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx index e02805e9..307b654b 100644 --- a/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/MonthlyDetailHeaderActions.stories.tsx @@ -9,14 +9,14 @@ const meta = { docs: { description: { component: - '월별 상세 페이지 헤더 액션 - 뒤로가기, 월 표시, 정렬 버튼을 포함합니다. 정렬 버튼을 클릭하여 Drawer를 열어보세요.', + '월별 상세 페이지의 헤더 액션 영역 컴포넌트입니다. 뒤로가기 버튼, 라벨·월 표시 영역, 정렬 버튼을 포함합니다. 정렬 버튼 클릭 시 드로어가 열려 기록 정렬 옵션을 선택할 수 있으며, 개인/그룹 기록함 모두에서 공통으로 사용됩니다.', }, }, }, tags: ['autodocs'], decorators: [ (Story) => ( -
+
@@ -51,7 +51,12 @@ export const Default: Story = { docs: { description: { story: - '내 기록함 월별 상세 헤더 - 정렬 버튼을 클릭하면 정렬 옵션 Drawer가 열립니다.', + ` +내 기록함 월별 상세 헤더 - 정렬 버튼을 클릭하면 정렬 옵션 Drawer가 열립니다. +- **뒤로가기 버튼**: 클릭 시 이전 페이지(아카이브)로 이동 +- **정렬 버튼**: 클릭 시 정렬 옵션 드로어 표시 (최신순 / 오래된순 등) +- **연월 표시**: \`month\` prop(YYYY-MM)을 읽기 쉬운 형식으로 변환하여 표시 + `, }, }, }, @@ -72,43 +77,3 @@ export const GroupRecords: Story = { }, }; -// 다른 월 -export const DifferentMonth: Story = { - args: { - month: '2024-06', - title: 'MY RECORDS', - }, - parameters: { - docs: { - description: { - story: '다른 월 표시 예시', - }, - }, - }, -}; - -// 다크 모드 -export const DarkMode: Story = { - args: { - month: '2025-01', - title: 'MY RECORDS', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( -
-
- -
-
- ), - ], -}; diff --git a/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx b/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx index f435108e..2ee9b75d 100644 --- a/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx +++ b/frontend/src/app/(post)/_components/storybook/MonthlyDetailRecords.stories.tsx @@ -1,172 +1,128 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MonthlyDetailRecords from '../MonthlyDetailRecords'; -import { DayRecord } from '@/lib/types/record'; +import { createMockDailyRecord, createMockGroupDailyRecords } from '@/lib/mocks/mock'; -const mockDayRecords: DayRecord[] = [ - { - date: '2025-01-15', - dayName: '수', - title: '한강 산책', - author: '나', - count: 3, - coverUrl: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-12', - dayName: '일', - title: '북한산 등산', - author: '나', - count: 5, - coverUrl: - 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-08', - dayName: '수', - title: '카페 투어', - author: '나', - count: 2, - coverUrl: - 'https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-03', - dayName: '금', - title: '연극 관람', - author: '나', - count: 1, - coverUrl: - 'https://images.unsplash.com/photo-1467003909585-2f8a72700288?auto=format&fit=crop&q=80&w=400', - }, - { - date: '2025-01-01', - dayName: '수', - title: '새해 일출', - author: '나', - count: 4, - coverUrl: - 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&q=80&w=400', - }, -]; +// MonthlyDetailRecords는 내부에서 useSuspenseQuery로 데이터를 fetching한다. +// setQueryData의 키는 각 queryOptions의 queryKey와 정확히 일치해야 한다. +// select 변환(convertDayRecords)은 TanStack Query가 자동으로 적용한다. + +const MONTH = '2025-01'; +const GROUP_ID = 'group-123'; + +function makeClient({ isGroup = false, empty = false }: { isGroup?: boolean; empty?: boolean } = {}) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + const data = empty ? [] : isGroup ? createMockGroupDailyRecords() : createMockDailyRecord(); + + if (isGroup) { + client.setQueryData(['group', GROUP_ID, 'records', 'daily', MONTH], data); + } else { + client.setQueryData(['my', 'records', 'daily', MONTH], data); + } + + return client; +} + +const clients = { + default: makeClient(), + group: makeClient({ isGroup: true }), + few: (() => { + const c = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + c.setQueryData(['my', 'records', 'daily', MONTH], createMockDailyRecord().slice(0, 2)); + return c; + })(), + single: (() => { + const c = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + c.setQueryData(['my', 'records', 'daily', MONTH], createMockDailyRecord().slice(0, 1)); + return c; + })(), + empty: makeClient({ empty: true }), +}; const meta = { title: 'Record/MonthlyDetailRecords', component: MonthlyDetailRecords, parameters: { - layout: 'padded', - docs: {}, + layout: 'fullscreen', + docs: { + description: { + component: ` +월별 상세 페이지의 일별 기록 카드 그리드 컴포넌트. + +해당 월의 기록을 날짜별 카드로 표시하며, 카드 클릭 시 해당 날짜의 일별 상세 페이지(\`routePath/{날짜}\`)로 이동한다. +하단의 지도 보기 버튼으로 해당 월의 기록을 지도에서 확인할 수 있다. +기록이 없는 경우 빈 상태 UI를 표시한다. + `, + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], argTypes: { - dayRecords: { - description: '일별 기록 데이터 배열', - }, - routePath: { - description: '일별 상세 페이지 라우트 경로', - }, - viewMapRoutePath: { - description: '지도 보기 라우트 경로', - }, + month: { description: '조회할 월 (YYYY-MM 형식)' }, + routePath: { description: '날짜 카드 클릭 시 이동할 기본 경로. 뒤에 /{날짜}가 붙는다.' }, + viewMapRoutePath: { description: '지도 보기 버튼 클릭 시 이동할 경로.' }, + groupId: { description: '그룹 기록함이면 그룹 ID를 전달. 없으면 개인 기록함.' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 내 기록함 월별 상세 export const Default: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, + args: { month: MONTH, routePath: '/my/detail', viewMapRoutePath: `/my/map/month/${MONTH}` }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { - docs: { - description: { - story: '내 기록함 - 월별 상세 (일별 카드 그리드)', - }, - }, + docs: { description: { story: '개인 기록함 — 월별 상세 일별 카드 그리드' } }, }, }; -// 그룹 기록함 월별 상세 export const GroupRecords: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/group/group-123/detail', - viewMapRoutePath: '/group/group-123/map/month/2025-01', - }, - parameters: { - docs: { - description: { - story: '그룹 기록함 - 월별 상세 (일별 카드 그리드)', - }, - }, - }, -}; - -// 기록이 적은 경우 -export const FewRecords: Story = { - args: { - dayRecords: mockDayRecords.slice(0, 2), - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, - parameters: { - docs: { - description: { - story: '기록이 적은 경우 (2개)', - }, - }, - }, -}; - -// 기록이 하나인 경우 -export const SingleRecord: Story = { - args: { - dayRecords: [mockDayRecords[0]], - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, + args: { month: MONTH, routePath: `/group/${GROUP_ID}/detail`, viewMapRoutePath: `/group/${GROUP_ID}/map/month/${MONTH}`, groupId: GROUP_ID }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { - docs: { - description: { - story: '기록이 하나만 있는 경우', - }, - }, + docs: { description: { story: '그룹 기록함 — 월별 상세 일별 카드 그리드' } }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - dayRecords: mockDayRecords, - routePath: '/my/detail', - viewMapRoutePath: '/my/map/month/2025-01', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, +export const EmptyRecords: Story = { + args: { month: MONTH, routePath: '/my/detail', viewMapRoutePath: `/my/map/month/${MONTH}` }, decorators: [ (Story) => ( -
-
- + +
+ 로딩 중...
}> + +
-
+ ), ], + parameters: { + docs: { description: { story: '기록이 없는 경우 — 빈 상태 UI 표시' } }, + }, }; + diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx index 8456ec58..5e8e61dc 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupDangerousZone.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import GroupDangerousZone from '../GroupDangerousZone'; import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GroupMember } from '@/lib/types/groupResponse'; const queryClient = new QueryClient({ defaultOptions: { @@ -16,6 +17,12 @@ const meta = { component: GroupDangerousZone, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 그룹 삭제 영역 컴포넌트입니다. 그룹 이름을 표시하고 "그룹 삭제" 버튼을 통해 확인 드로어를 열어 최종 삭제를 진행합니다. 관리자(admin)만 접근 가능한 영역입니다.', + }, + }, msw: { handlers: [ http.delete('/api/groups/:groupId', () => { @@ -28,7 +35,7 @@ const meta = { decorators: [ (Story) => ( -
+
@@ -39,57 +46,35 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - groupName: '우리 가족', - groupId: 'group-1', - }, - parameters: { - docs: { - description: { - story: '그룹 삭제 영역 - 기본 상태', - }, - }, - }, +const meAdmin: GroupMember = { + userId: 'user-1', + name: '나', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '관리자', + joinedAt: '2024-01-01T00:00:00Z', }; -export const LongGroupName: Story = { - args: { - groupName: '우리 가족의 소중한 추억 모음집', - groupId: 'group-1', - }, - parameters: { - docs: { - description: { - story: '긴 그룹 이름인 경우', - }, - }, - }, +const meViewer: GroupMember = { + ...meAdmin, + role: 'VIEWER', + nicknameInGroup: '뷰어', }; -export const DarkMode: Story = { +export const Default: Story = { args: { groupName: '우리 가족', groupId: 'group-1', - className: 'dark', + me: meAdmin, }, parameters: { - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: '그룹 삭제 영역 - 기본 상태', }, }, }, - decorators: [ - (Story) => ( - -
-
- -
-
-
- ), - ], }; + + + diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx index fdf6d04f..5fe2ca0f 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx @@ -1,33 +1,62 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GroupInfo from '../GroupInfo'; import { GroupEditProvider } from '../GroupEditContext'; import { Member } from '@/lib/types/group'; +import { GroupMember } from '@/lib/types/groupResponse'; + +// 커버 이미지 클릭 시 GalleryDrawer가 열리고 내부에서 useInfiniteQuery를 사용하므로 +// QueryClientProvider가 필요하다. + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, +}); const mockMembers: Member[] = [ { id: 1, name: '도비', avatar: '/profile-ex.jpeg', role: 'admin' }, { id: 2, name: '하니', avatar: '/profile-ex.jpeg', role: 'member' }, ]; +const meAdmin: GroupMember = { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', +}; + +const meViewer: GroupMember = { + ...meAdmin, + role: 'VIEWER', + nicknameInGroup: '뷰어', +}; + +function Wrapper({ name, children }: { name: string; children: React.ReactNode }) { + return ( + + +
+ {children} +
+
+
+ ); +} + const meta = { title: 'Group/GroupInfo', component: GroupInfo, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 그룹명/커버 편집 폼 컴포넌트입니다. 그룹 커버 이미지 변경, 그룹명 입력(유효성 검사 포함), 저장 버튼을 제공합니다. GroupEditProvider 컨텍스트를 통해 편집 상태를 관리합니다.', + }, + }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( - -
- -
-
- ), - ], } satisfies Meta; export default meta; @@ -36,27 +65,25 @@ type Story = StoryObj; export const Default: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '그룹 정보 수정 - 기본 상태', + story: ` +그룹 정보 수정 — 기본 상태 (ADMIN 권한) +- **커버 이미지 클릭**: 이미지 선택 드로어가 열려 커버 변경 가능 +- **그룹명 입력**: 실시간 입력, 빈 값 또는 2자 미만이면 저장 버튼 비활성화 +- **저장 버튼**: 변경 사항을 서버에 저장 + `, }, }, }, @@ -65,27 +92,20 @@ export const Default: Story = { export const LongGroupName: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '긴 그룹 이름', + story: '긴 그룹 이름 — 입력 길이 초과 시 유효성 에러 표시', }, }, }, @@ -94,60 +114,22 @@ export const LongGroupName: Story = { export const EmptyGroupName: Story = { args: { groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meAdmin, }, decorators: [ (Story) => ( - -
- -
-
+ + + ), ], parameters: { docs: { description: { - story: '그룹 이름이 비어있는 경우', + story: '그룹 이름이 비어있는 경우 — 저장 버튼 비활성화', }, }, }, }; -export const DarkMode: Story = { - args: { - groupId: 'group-1', - groupThumnail: - 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', - nickname: '도비', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- -
-
-
- ), - ], -}; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx index 906fcd67..fa9732fa 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx @@ -25,6 +25,12 @@ const meta = { component: GroupMemberManagement, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 설정 페이지의 멤버 목록과 역할 관리 컴포넌트입니다. 그룹 멤버를 아바타/닉네임/역할과 함께 목록으로 표시하며, 관리자는 멤버 추방 버튼을 통해 멤버를 내보낼 수 있습니다. 멤버 추방 시 DELETE API를 호출합니다.', + }, + }, msw: { handlers: [ http.delete('/api/:groupId/members/:memberId', () => { @@ -139,35 +145,36 @@ export const ManyMembers: Story = { }, }; -export const DarkMode: Story = { +export const Interactive: Story = { args: { members: mockMembers, groupId: 'group-1', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, }, decorators: [ (Story) => ( -
- -
- -
-
-
+ +
+ +
+
), ], + parameters: { + docs: { + description: { + story: ` +- **추방 버튼 클릭**: 해당 멤버에게 추방 확인 드로어 표시 +- **추방 확인**: "추방하기" 버튼 클릭 시 DELETE API 호출 후 목록에서 제거 +- **관리자(admin)**: 추방 버튼이 비활성화되어 자신을 추방할 수 없음 + `, + }, + }, + }, }; + diff --git a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx index 2e9af7d2..fc7db1ae 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx @@ -12,6 +12,12 @@ const meta = { component: GroupProfileEditClient, parameters: { layout: 'fullscreen', + docs: { + description: { + component: + '그룹 내 내 프로필(닉네임/이미지) 편집 페이지 컴포넌트입니다. 그룹 전용 닉네임과 프로필 이미지를 변경할 수 있으며, 닉네임 유효성 검사(2~10자)를 포함합니다. groupId와 groupProfile prop을 통해 초기 데이터를 받습니다.', + }, + }, nextjs: { appDirectory: true, navigation: { @@ -97,26 +103,22 @@ export const LongNickname: Story = { }, }; -export const DarkMode: Story = { +export const Interactive: Story = { args: { groupId: 'group-1', groupProfile: mockGroupProfile, }, parameters: { - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: ` +- **프로필 이미지 클릭**: 이미지 파일 선택 피커가 열리며, 선택 즉시 미리보기 업데이트 +- **닉네임 입력**: 실시간 입력, 2자 미만 또는 10자 초과 시 에러 메시지 표시 +- **저장 버튼**: 유효성 통과 시 활성화, 클릭 시 변경 사항을 서버에 저장 +- **뒤로가기**: 저장하지 않고 이전 페이지로 이동 + `, }, }, }, - decorators: [ - (Story) => ( -
-
- -
-
- ), - ], }; + diff --git a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx index aab2fc0b..6519215a 100644 --- a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx +++ b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx @@ -29,6 +29,12 @@ const meta = { component: GroupHeader, parameters: { layout: 'padded', + docs: { + description: { + component: + '그룹 홈 헤더의 멤버 아바타와 설정 버튼 컴포넌트입니다. 그룹 이름, 참여 멤버 아바타(최대 표시 후 +N 처리), 그룹 설정 페이지 이동 버튼을 포함합니다. MSW를 통해 그룹 정보를 목킹하며 groupId 기반으로 데이터를 가져옵니다.', + }, + }, msw: { handlers: [ // 호출되는 API 주소에 따라 다른 데이터를 반환하도록 설정 @@ -112,24 +118,18 @@ export const ManyMembers: Story = { }, }; -export const DarkMode: Story = { - args: { - className: 'dark', - }, +export const Interactive: Story = { parameters: { - layout: 'padded', - backgrounds: { default: 'dark' }, docs: { description: { - story: '다크 모드', + story: ` +- **멤버 아바타 클릭**: 멤버 목록 상세 또는 프로필 페이지로 이동 +- **멤버 +N 표시**: 최대 노출 수 초과 시 나머지 멤버 수를 +N으로 축약 표시 +- **설정 버튼 클릭**: 그룹 설정 페이지로 이동 +- **그룹 이름 말줄임**: 이름이 길면 말줄임 처리(LongGroupName 스토리 참고) + `, }, }, }, - render: (args) => ( -
-
- -
-
- ), }; + diff --git a/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx b/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx index d0ef59b4..a6699cd0 100644 --- a/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx +++ b/frontend/src/app/(post)/record/_components/storybook/RecordDetail.stories.tsx @@ -44,7 +44,8 @@ const mockBlocks: Block[] = [ id: 'block-4', type: 'IMAGE', value: { - mediaIds: ['media-1', 'media-2'], + // mediaIds를 넣으면 /api/media-image/:id 프록시를 우선 사용해 Storybook에서 이미지가 깨짐. + // tempUrls만 두면 Unsplash URL을 직접 사용한다. tempUrls: [ 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', 'https://images.unsplash.com/photo-1418985991508-e47386d96a71?auto=format&fit=crop&q=80&w=400', @@ -72,7 +73,7 @@ const mockBlocks: Block[] = [ { id: 'block-7', type: 'MOOD', - value: { mood: '😊' }, + value: { mood: '행복' }, layout: { row: 6, col: 1, span: 1 }, }, { @@ -123,6 +124,12 @@ const meta = { component: RecordDetail, parameters: { layout: 'padded', + docs: { + description: { + component: + '기록 상세 페이지 컴포넌트입니다. 블록 기반 기록 본문(날짜, 시간, 텍스트, 이미지, 위치, 태그, 무드, 평점)과 작성자/기여자 섹션을 표시합니다.', + }, + }, nextjs: { appDirectory: true, }, @@ -145,10 +152,12 @@ const meta = { decorators: [ (Story) => ( -
- }> - - +
+
+ }> + + +
), @@ -165,7 +174,23 @@ export const Default: Story = { parameters: { docs: { description: { - story: '내 기록함 상세 페이지 - 기본 뷰', + story: ` +내 기록함 상세 페이지 - 기본 뷰 +- **블록 렌더링**: 블록 타입(DATE, TIME, TEXT, IMAGE, LOCATION, TAG, MOOD, RATING)에 따라 기록 본문이 동적으로 구성됨 +- **이미지 블록**: 여러 이미지를 갤러리 형태로 표시 +- **기여자 섹션**: 그룹 기록의 경우 기여자(EDITOR) 아바타 목록 표시 + +**헤더 우측 ⋯ 버튼 (Popover)** + +작성자 본인 / 그룹 EDITOR 이상인 경우에만 표시됩니다. 버튼을 클릭하면 Popover가 열리며 아래 옵션이 나타납니다. + +| 옵션 | 동작 | +|---|---| +| 공유하기 | 공유 링크가 이미 있으면 공유 드로어 오픈, 없으면 링크 생성 후 드로어 오픈 | +| 공유 링크 생성 / 해제 | 공유 토큰 유무에 따라 토글 — 링크 생성 또는 링크 해제 API 호출 | +| 수정하기 | 개인 기록: \`/add?mode=edit&postId={id}\` 이동 · 그룹 기록: 편집 세션 시작 후 편집 페이지 이동 | +| 삭제하기 | 삭제 확인 Drawer 오픈 → "삭제하기" 클릭 시 기록 삭제 후 이전 페이지로 이동 | + ` }, }, }, @@ -197,29 +222,3 @@ export const MinimalBlocks: Story = { }, }; -export const DarkMode: Story = { - args: { - recordId: 'record-1', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( - -
-
- }> - - -
-
-
- ), - ], -}; diff --git a/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx b/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx index e0c402cf..c1a187ae 100644 --- a/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx +++ b/frontend/src/components/storybook/DailyDetailRecordItem.stories.tsx @@ -1,23 +1,23 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DailyDetailRecordItem from '../DailyDetailRecordItem'; -import { RecordPreview } from '@/lib/types/recordResponse'; -import { Member } from '@/lib/types/group'; +import { Contributor, RecordPreview } from '@/lib/types/recordResponse'; -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 0, - }, +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 0, }, - }); + }, +}); const mockRecord: RecordPreview = { postId: 'record-1', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '성수동 카페 투어', eventAt: '2025-01-15T14:30:00Z', createdAt: '2025-01-15T15:00:00Z', @@ -68,77 +68,35 @@ const mockRecord: RecordPreview = { ], }; -const mockRecordWithLocation: RecordPreview = { - ...mockRecord, - postId: 'record-2', - title: '한남동 브런치', - blocks: [ - { - id: 'time-2', - type: 'TIME', - value: { time: '11:00' }, - layout: { row: 0, col: 1, span: 1 }, - }, - { - id: 'text-2', - type: 'TEXT', - value: { text: '에그 베네딕트가 정말 맛있었다!' }, - layout: { row: 1, col: 1, span: 2 }, - }, - { - id: 'loc-2', - type: 'LOCATION', - value: { - lat: 37.5347, - lng: 127.0008, - address: '서울 용산구 한남동', - placeName: '카페 라떼', - }, - layout: { row: 2, col: 1, span: 2 }, - }, - { - id: 'rating-2', - type: 'RATING', - value: { rating: 5 }, - layout: { row: 3, col: 1, span: 2 }, - }, - ], -}; +// 그룹 기록 참여자 (3명) — groupProfileImageId가 null이면 기본 프로필 이미지 표시 +const mockContributors: Contributor[] = [ + { userId: 'user-1', role: 'AUTHOR', nickname: '김철수', groupNickname: '철수', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-2', role: 'CONTRIBUTOR', nickname: '이영희', groupNickname: '영희', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-3', role: 'CONTRIBUTOR', nickname: '박지민', groupNickname: '지민', groupProfileImageId: null, profileImageId: null }, +]; + +// 참여자 6명 — 앞 4개 아바타 + +2 축약 표시 확인용 +const mockManyContributors: Contributor[] = [ + ...mockContributors, + { userId: 'user-4', role: 'CONTRIBUTOR', nickname: '최수진', groupNickname: '수진', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-5', role: 'CONTRIBUTOR', nickname: '정민준', groupNickname: '민준', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-6', role: 'CONTRIBUTOR', nickname: '한지수', groupNickname: '지수', groupProfileImageId: null, profileImageId: null }, +]; const mockGroupRecord: RecordPreview = { ...mockRecord, - postId: 'record-3', + postId: 'record-2', scope: 'GROUP', groupId: 'group-123', title: '팀 회식', + contributors: mockContributors, }; -const mockMembers: Member[] = [ - { - id: 1, - name: '김철수', - avatar: - 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100', - }, - { - id: 2, - name: '이영희', - avatar: - 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100', - }, - { - id: 3, - name: '박지민', - avatar: - 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=100', - }, - { - id: 4, - name: '최수진', - avatar: - 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100', - }, -]; +const mockGroupRecordManyMembers: RecordPreview = { + ...mockGroupRecord, + postId: 'record-3', + contributors: mockManyContributors, +}; const meta = { title: 'Record/DailyDetailRecordItem', @@ -146,18 +104,18 @@ const meta = { parameters: { layout: 'padded', docs: { - story: { inline: false, height: '450px' }, + story: { height: '450px' }, description: { component: - '일별 상세 기록 아이템 - 타임라인의 각 기록 카드를 표시합니다. 블록 레이아웃에 따라 동적으로 렌더링됩니다.', + '일별 상세 페이지에서 기록 하나를 표시하는 카드 컴포넌트입니다. 블록 타입(IMAGE, TEXT, TAG, RATING, LOCATION 등)에 따라 카드 내용이 동적으로 구성됩니다.', }, }, }, tags: ['autodocs'], decorators: [ (Story) => ( - -
+ +
@@ -168,13 +126,10 @@ const meta = { ], argTypes: { record: { - description: '기록 데이터 (RecordPreview)', + description: '기록 데이터 (RecordPreview). contributors 배열에 참여자가 있고 groupId prop이 전달될 때 아바타가 표시됩니다.', }, groupId: { - description: '그룹 ID (그룹 기록일 때)', - }, - members: { - description: '그룹 멤버 목록 (그룹 기록일 때 아바타 표시)', + description: '그룹 ID. 전달 시 record.contributors 아바타와 그룹 상세 경로(/record/{id}?scope=group&groupId=…)로 이동합니다.', }, }, } satisfies Meta; @@ -182,7 +137,6 @@ const meta = { export default meta; type Story = StoryObj; -// 기본: 개인 기록 export const Default: Story = { args: { record: mockRecord, @@ -190,83 +144,27 @@ export const Default: Story = { parameters: { docs: { description: { - story: '개인 기록 - 이미지, 텍스트, 태그, 평점 블록 포함', - }, - }, - }, -}; - -// 위치 정보가 있는 기록 -export const WithLocation: Story = { - args: { - record: mockRecordWithLocation, - }, - parameters: { - docs: { - description: { - story: '위치 정보가 포함된 기록', + story: ` +개인 기록 — 이미지, 텍스트, 태그, 평점 블록 포함 +- **카드 클릭**: 기록 상세 페이지(\`/record/{id}\`)로 이동 +- **블록 조건부 렌더링**: 블록 타입에 따라 카드 내용이 달라짐 + `, }, }, }, }; -// 그룹 기록 (멤버 아바타 표시) -export const GroupRecord: Story = { - args: { - record: mockGroupRecord, - groupId: 'group-123', - members: mockMembers, - }, - parameters: { - docs: { - description: { - story: '그룹 기록 - 참여 멤버 아바타가 표시됩니다', - }, - }, - }, -}; - -// 그룹 기록 (멤버가 3명 이상) export const GroupRecordManyMembers: Story = { args: { - record: mockGroupRecord, + record: mockGroupRecordManyMembers, groupId: 'group-123', - members: mockMembers, }, parameters: { docs: { description: { - story: '그룹 기록 - 멤버가 3명 이상일 때 +N 표시', + story: '그룹 기록 — 참여자가 4명을 초과할 때 앞 4개 아바타 + +N으로 축약 표시됩니다.', }, }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - record: mockRecord, - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, - decorators: [ - (Story) => ( - -
-
-
-
- -
-
-
- - ), - ], -}; diff --git a/frontend/src/components/storybook/DailyDetailRecords.stories.tsx b/frontend/src/components/storybook/DailyDetailRecords.stories.tsx index 1f74d80b..e050b064 100644 --- a/frontend/src/components/storybook/DailyDetailRecords.stories.tsx +++ b/frontend/src/components/storybook/DailyDetailRecords.stories.tsx @@ -1,45 +1,41 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DailyDetailRecords from '../DailyDetailRecords'; -import { RecordPreview } from '@/lib/types/recordResponse'; -import { ActiveMember } from '@/lib/types/group'; +import { Contributor, RecordPreview } from '@/lib/types/recordResponse'; -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 0, - }, - }, - }); +// DailyDetailRecords는 내부에서 useSuspenseQuery(recordPreviewListOptions)로 데이터를 가져온다. +// setQueryData의 키는 recordPreviewListOptions의 queryKey와 정확히 일치해야 한다. +// - personal: ['my', 'records', 'preview', date, 'personal'] +// - groups: ['group', groupId, 'records', 'daily', date] + +const DATE = '2025-01-15'; +const GROUP_ID = 'group-123'; + +const mockContributors: Contributor[] = [ + { userId: 'user-1', role: 'AUTHOR', nickname: '김철수', groupNickname: '철수', groupProfileImageId: null, profileImageId: null }, + { userId: 'user-2', role: 'CONTRIBUTOR', nickname: '이영희', groupNickname: '영희', groupProfileImageId: null, profileImageId: null }, +]; -const mockMemories: RecordPreview[] = [ +const mockRecords: RecordPreview[] = [ { postId: 'record-1', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '성수동 카페 투어', eventAt: '2025-01-15T14:30:00Z', createdAt: '2025-01-15T15:00:00Z', updatedAt: '2025-01-15T15:00:00Z', - location: { - lat: 37.5445, - lng: 127.0567, - address: '서울 성동구 성수동2가', - placeName: '어니언 성수', - }, + location: { lat: 37.5445, lng: 127.0567, address: '서울 성동구 성수동2가', placeName: '어니언 성수' }, tags: ['카페', '성수', '디저트'], rating: 4, blocks: [ { id: 'image-1', type: 'IMAGE', - value: { - tempUrls: [ - 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', - ], - }, + value: { tempUrls: ['https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400'] }, layout: { row: 1, col: 1, span: 2 }, }, { @@ -66,16 +62,13 @@ const mockMemories: RecordPreview[] = [ postId: 'record-2', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '점심 브런치', eventAt: '2025-01-15T11:00:00Z', createdAt: '2025-01-15T12:00:00Z', updatedAt: '2025-01-15T12:00:00Z', - location: { - lat: 37.5347, - lng: 127.0008, - address: '서울 용산구 한남동', - placeName: '브런치 카페', - }, + location: { lat: 37.5347, lng: 127.0008, address: '서울 용산구 한남동', placeName: '브런치 카페' }, tags: ['브런치', '한남동'], rating: 5, blocks: [ @@ -97,16 +90,13 @@ const mockMemories: RecordPreview[] = [ postId: 'record-3', scope: 'ME', groupId: null, + contributors: [], + emotion: [], title: '아침 조깅', eventAt: '2025-01-15T07:00:00Z', createdAt: '2025-01-15T08:00:00Z', updatedAt: '2025-01-15T08:00:00Z', - location: { - lat: 37.5171, - lng: 127.0416, - address: '서울 송파구 잠실동', - placeName: '한강공원', - }, + location: { lat: 37.5171, lng: 127.0416, address: '서울 송파구 잠실동', placeName: '한강공원' }, tags: ['운동', '조깅'], rating: null, blocks: [ @@ -119,49 +109,34 @@ const mockMemories: RecordPreview[] = [ { id: 'loc-3', type: 'LOCATION', - value: { - lat: 37.5171, - lng: 127.0416, - address: '서울 송파구 잠실동', - placeName: '한강공원', - }, + value: { lat: 37.5171, lng: 127.0416, address: '서울 송파구 잠실동', placeName: '한강공원' }, layout: { row: 2, col: 1, span: 2 }, }, ], }, ]; -const mockGroupMemories: RecordPreview[] = [ - { - ...mockMemories[0], - scope: 'GROUP', - groupId: 'group-123', - }, - { - ...mockMemories[1], - scope: 'GROUP', - groupId: 'group-123', - }, -]; +const mockGroupRecords: RecordPreview[] = mockRecords.map((r, i) => ({ + ...r, + scope: 'GROUP', + groupId: GROUP_ID, + contributors: i === 0 ? mockContributors : [mockContributors[0]], +})); -const mockMembers: ActiveMember[] = [ - { - id: 1, - name: '김철수', - avatar: - 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100', - role: 'admin', - recordId: 'record-1', - }, - { - id: 2, - name: '이영희', - avatar: - 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100', - role: 'member', - recordId: 'record-2', - }, -]; +function makeClient(queryKey: unknown[], data: RecordPreview[]) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKey, data); + return client; +} + +const clients = { + default: makeClient(['my', 'records', 'preview', DATE, 'personal'], mockRecords), + group: makeClient(['group', GROUP_ID, 'records', 'daily', DATE], mockGroupRecords), + single: makeClient(['my', 'records', 'preview', DATE, 'personal'], [mockRecords[0]]), + empty: makeClient(['my', 'records', 'preview', DATE, 'personal'], []), +}; const meta = { title: 'Record/DailyDetailRecords', @@ -169,111 +144,89 @@ const meta = { parameters: { layout: 'padded', docs: { - story: { inline: false, height: '700px' }, + story: { height: '700px' }, + description: { + component: + '일별 상세 페이지에서 해당 날짜의 기록 목록을 타임라인 형태로 표시하는 컴포넌트입니다. 내부에서 `recordPreviewListOptions`로 데이터를 직접 fetching하며, `scope="personal"` 또는 `scope="groups"` + `groupId`에 따라 개인/그룹 기록을 구분합니다. 기록이 없을 경우 빈 상태 UI를 표시합니다.', + }, }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( - -
- -
-
- ), - ], argTypes: { - memories: { - description: '기록 목록 (RecordPreview[])', - }, - members: { - description: '그룹 멤버 목록 (그룹 기록일 때만)', - }, + date: { description: '조회할 날짜 (YYYY-MM-DD 형식)', control: 'text' }, + scope: { description: '"personal" | "groups"', control: 'inline-radio', options: ['personal', 'groups'] }, + groupId: { description: '그룹 기록일 때 그룹 ID', control: 'text' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 개인 기록 export const Default: Story = { - args: { - memories: mockMemories, - }, + args: { date: DATE, scope: 'personal' }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+
+ ), + ], parameters: { docs: { description: { - story: '개인 일별 상세 - 타임라인 형태의 기록 목록', + story: ` +개인 일별 상세 — 타임라인 형태의 기록 목록 +- **카드 클릭**: 기록 상세 페이지(\`/record/{id}\`)로 이동 +- **타임라인 구조**: 세로 줄과 원형 마커로 시간순 기록 흐름을 시각화 + `, }, }, }, }; -// 그룹 기록 export const GroupRecords: Story = { - args: { - memories: mockGroupMemories, - members: mockMembers, - }, - parameters: { - docs: { - description: { - story: '그룹 일별 상세 - 멤버 정보와 함께 표시', - }, - }, - }, -}; - -// 기록이 하나인 경우 -export const SingleRecord: Story = { - args: { - memories: [mockMemories[0]], - }, - parameters: { - docs: { - description: { - story: '기록이 하나만 있는 경우', - }, - }, - }, -}; - -// 기록이 많은 경우 -export const ManyRecords: Story = { - args: { - memories: [...mockMemories, ...mockMemories], - }, + args: { date: DATE, scope: 'groups', groupId: GROUP_ID }, + decorators: [ + (Story) => ( + +
+ 로딩 중...
}> + + +
+ + ), + ], parameters: { docs: { description: { - story: '기록이 많은 경우 - 스크롤 확인', + story: '그룹 일별 상세 — 참여자 아바타가 각 카드에 표시됩니다.', }, }, }, }; -// 다크 모드 -export const DarkMode: Story = { - args: { - memories: mockMemories, - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '다크 모드', - }, - }, - }, +export const EmptyRecords: Story = { + args: { date: DATE, scope: 'personal' }, decorators: [ (Story) => ( - -
-
+ +
+ 로딩 중...
}> -
+
), ], + parameters: { + docs: { + description: { story: '기록이 없는 경우 — 빈 상태 UI 표시' }, + }, + }, }; + diff --git a/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx b/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx index efb44efb..8f2dbb6d 100644 --- a/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx +++ b/frontend/src/components/storybook/DateSelectorDrawer.stories.tsx @@ -1,6 +1,36 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import DateSelectorDrawer from '../DateSelectorDrawer'; +// DateSelectorDrawer는 내부에서 useQuery(myDailyRecordedDatesOption | groupDailyRecordedDatesOption)를 호출한다. +// enabled: isOpen 이지만 useQueryClient() 자체는 항상 실행되므로 QueryClientProvider가 필요하다. +// 드로어를 열었을 때 캘린더에 기록 점이 표시되도록 현재 월 데이터를 미리 주입한다. +// - personal 키: ['recordedDates', '/api/user/archives/record-days?month={YEAR}-{MONTH}'] +// - group 키: ['recordedDates', '/api/groups/{groupId}/archives/record-days?month={YEAR}-{MONTH}'] + +const NOW = new Date(); +const YEAR = NOW.getFullYear(); +const MONTH = String(NOW.getMonth() + 1).padStart(2, '0'); +// 현재 월의 기록된 날짜 (캘린더에 초록 점으로 표시됨) +const mockRecordedDates: string[] = [5, 10, 14, 20].map( + (d) => `${YEAR}-${MONTH}-${String(d).padStart(2, '0')}`, +); + +function makeClient(queryKey: unknown[], data: string[]) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKey, data); + return client; +} + +const clients = { + default: makeClient( + ['recordedDates', `/api/user/archives/record-days?month=${YEAR}-${MONTH}`], + mockRecordedDates, + ), +}; + const meta = { title: 'Record/DateSelectorDrawer', component: DateSelectorDrawer, @@ -9,69 +39,51 @@ const meta = { docs: { description: { component: - '날짜 선택 Drawer - 캘린더 뷰와 월 선택 뷰를 제공합니다. 버튼을 클릭하여 Drawer를 열어보세요.', + '홈/아카이브 헤더에서 날짜를 선택하는 드로어 컴포넌트입니다. 캘린더 아이콘 버튼을 클릭하면 드로어가 열리며, 날짜 클릭 시 `dayRoute`로, 월 전체보기 클릭 시 `monthRoute`로, 연도 전체보기 클릭 시 `yearRoute`로 이동합니다. 기록이 있는 날짜에는 초록 점이 표시되며, 미래 날짜는 선택이 비활성화됩니다. 월 헤더 클릭 시 연도·월 선택 피커로 전환됩니다.', }, }, }, tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], argTypes: { - dayRoute: { description: '일별 상세 페이지 라우트' }, - monthRoute: { description: '월별 상세 페이지 라우트' }, - yearRoute: { description: '연도별 페이지 라우트' }, + dayRoute: { description: '날짜 클릭 시 이동할 기본 경로. 뒤에 /{YYYY-MM-DD}가 붙습니다.', control: 'text' }, + monthRoute: { description: '"월 기록 전체보기" 클릭 시 이동할 기본 경로. 뒤에 /{YYYY-MM}이 붙습니다.', control: 'text' }, + yearRoute: { description: '"연도 기록 전체보기" 클릭 시 이동할 기본 경로. 뒤에 /{YYYY}가 붙습니다.', control: 'text' }, + groupId: { description: '그룹 기록함이면 그룹 ID를 전달. 없으면 개인 기록함 API를 사용합니다.', control: 'text' }, }, } satisfies Meta; export default meta; type Story = StoryObj; -// 기본: 내 기록함용 export const Default: Story = { args: { dayRoute: '/my/detail', monthRoute: '/my/month', yearRoute: '/my/year', }, -}; - -// 그룹 기록함용 -export const GroupRecords: Story = { - args: { - dayRoute: '/group/group-123/detail', - monthRoute: '/group/group-123/month', - yearRoute: '/group/group-123/year', - }, -}; - -// 💡 다크 모드 스토리 수정 -export const DarkMode: Story = { - args: { - dayRoute: '/my/detail', - monthRoute: '/my/month', - yearRoute: '/my/year', - className: 'dark', - }, - parameters: { - backgrounds: { default: 'dark' }, - docs: { - description: { - story: '포털 오염 없이 구현된 다크 모드 스토리입니다.', - }, - }, - }, decorators: [ (Story) => ( -
-
+ +
-
+ ), ], + parameters: { + docs: { + description: { + story: ` +개인 기록함 — 캘린더 아이콘을 클릭하면 드로어가 열립니다. +- **날짜 클릭**: \`dayRoute/{YYYY-MM-DD}\`로 이동 +- **월 기록 전체보기**: \`monthRoute/{YYYY-MM}\`으로 이동 +- **연도 기록 전체보기**: \`yearRoute/{YYYY}\`으로 이동 +- **월 헤더 클릭**: 연도·월 선택 피커(스크롤 휠)로 전환 +- **기록 있는 날짜**: 초록 점 표시 (드로어 열면 확인 가능) +- **미래 날짜**: 선택 비활성화 + `, + }, + }, + }, }; + diff --git a/frontend/src/lib/mocks/mock.ts b/frontend/src/lib/mocks/mock.ts index eb0dce7d..673e149d 100644 --- a/frontend/src/lib/mocks/mock.ts +++ b/frontend/src/lib/mocks/mock.ts @@ -176,6 +176,7 @@ export const createMockRecordPreviews = (date: string): RecordPreview[] => [ }, ], groupId: 'group-123', + groupName: '고3 전우들', title: '홍대 버스킹 구경', eventAt: `${date}T19:00:00Z`, createdAt: `${date}T21:00:00Z`, From 761daad05cbc6e969d20736f7af08e4b07ad4e90 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 20:54:07 +0900 Subject: [PATCH 22/39] =?UTF-8?q?fix:=20=ED=83=9C=EA=B7=B8=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20drawer=20=EA=B0=80=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EB=B0=94=20=EB=B0=8F=20=EC=9C=84=EC=B9=98=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그 drawer 가로 스크롤바 숨기기 - 기록 작성의 위치 블록 다른 블록들과 같은 정렬로 동작하도록 수정 --- frontend/src/app/(search)/_components/TagSearchDrawer.tsx | 2 +- frontend/src/components/map/LocationField.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/(search)/_components/TagSearchDrawer.tsx b/frontend/src/app/(search)/_components/TagSearchDrawer.tsx index 2bd357c3..79048a7d 100644 --- a/frontend/src/app/(search)/_components/TagSearchDrawer.tsx +++ b/frontend/src/app/(search)/_components/TagSearchDrawer.tsx @@ -58,7 +58,7 @@ export default function TagSearchDrawer({ !open && onClose()}>
-
+
diff --git a/frontend/src/components/map/LocationField.tsx b/frontend/src/components/map/LocationField.tsx index efc95815..f52dbcc5 100644 --- a/frontend/src/components/map/LocationField.tsx +++ b/frontend/src/components/map/LocationField.tsx @@ -23,7 +23,7 @@ export function LocationField({ const address = location?.address; const placeName = location?.placeName; return ( -
+
{!address ? ( From 864b3c91d9078874f64984cf1ea6dca762face72 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 20:56:30 +0900 Subject: [PATCH 23/39] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 결과 아이템 썸네일 있음/없음 등에 대한 결과 목록 뷰 - 검색 필터 drawer 뷰 --- .../storybook/FilterDrawers.stories.tsx | 287 ++++++++++++++++++ .../storybook/SearchItem.stories.tsx | 123 ++++++++ .../storybook/TagSearchDrawer.stories.tsx | 122 ++++++++ 3 files changed, 532 insertions(+) create mode 100644 frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx create mode 100644 frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx create mode 100644 frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx diff --git a/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx b/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx new file mode 100644 index 00000000..bf98dbe5 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/FilterDrawers.stories.tsx @@ -0,0 +1,287 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { FilterChip } from '@/components/search/FilterChip'; +import EmotionDrawer from '@/app/(post)/_components/editor/emotion/EmotionDrawer'; +import DateDrawer from '@/components/DateDrawer'; +import TagSearchDrawer from '../TagSearchDrawer'; + +import { createMockTagStats } from '@/lib/mocks/mock'; + +const mockTagsQueryHandler = http.get('/api/stats/tags', () => + HttpResponse.json({ success: true, data: createMockTagStats() }), +); + +function makeTagClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['profile', 'tags', 'summary', 10], createMockTagStats()); + return client; +} + +const tagClient = makeTagClient(); + +type ActiveDrawer = 'tag' | 'emotion' | 'date' | null; + +// ─── 필터 바 전체 흐름 래퍼 ────────────────────────────────────────────────── +function SearchFilterBar({ + initialTags = [], + initialEmotions = [], + initialDateRange = { start: null, end: null }, +}: { + initialTags?: string[]; + initialEmotions?: string[]; + initialDateRange?: { start: string | null; end: string | null }; +}) { + const [tags, setTags] = useState(initialTags); + const [emotions, setEmotions] = useState(initialEmotions); + const [dateRange, setDateRange] = useState(initialDateRange); + const [activeDrawer, setActiveDrawer] = useState(null); + + const tagLabel = tags.length > 0 ? tags.map((t) => `#${t}`).join(', ') : '태그'; + const emotionLabel = emotions.length > 0 ? emotions.join(', ') : '감정'; + const dateLabel = + dateRange.start + ? dateRange.end + ? `${dateRange.start} ~ ${dateRange.end}` + : dateRange.start + : '날짜'; + + return ( + +
+ {/* 필터 칩 바 */} +
+ 0} + onClick={() => setActiveDrawer('tag')} + onClear={() => setTags([])} + /> + 0} + onClick={() => setActiveDrawer('emotion')} + onClear={() => setEmotions([])} + /> + setActiveDrawer('date')} + onClear={() => setDateRange({ start: null, end: null })} + /> +
+ + {/* 선택된 값 표시 */} +
+

태그: {tags.length > 0 ? tags.join(', ') : '없음'}

+

감정: {emotions.length > 0 ? emotions.join(', ') : '없음'}

+

날짜: {dateRange.start ?? '없음'}{dateRange.end ? ` ~ ${dateRange.end}` : ''}

+
+
+ + {/* 드로어들 */} + {activeDrawer === 'tag' && ( + + setTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ) + } + onReset={() => setTags([])} + onClose={() => setActiveDrawer(null)} + /> + )} + + + setEmotions((prev) => + prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e], + ) + } + onReset={() => setEmotions([])} + onClose={() => setActiveDrawer(null)} + mode="search" + /> + + {activeDrawer === 'date' && ( + { + setDateRange(r); + setActiveDrawer(null); + }} + onClose={() => setActiveDrawer(null)} + /> + )} +
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'Search/FilterDrawers', + component: SearchFilterBar, + parameters: { + layout: 'centered', + nextjs: { appDirectory: true }, + msw: { handlers: [mockTagsQueryHandler] }, + docs: { + description: { + component: ` +검색 페이지의 필터 칩(\`FilterChip\`)을 클릭하면 열리는 드로어 모음. + +각 칩 클릭 → 드로어 열림 → 선택/확인 → 칩 활성화의 전체 흐름을 확인할 수 있다. + +| 칩 타입 | 드로어 | 특이사항 | +|---------|--------|---------| +| 태그 | \`TagSearchDrawer\` | 다중 선택, 직접 입력 가능 | +| 감정 | \`EmotionDrawer\` (mode="search") | 다중 선택, 초기화 버튼 추가 | +| 날짜 | \`DateDrawer\` (mode="range") | 시작~종료 기간 선택 | +| 위치 | — | Google Maps — 스토리 제외 | + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ───────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: {}, + parameters: { + docs: { + description: { + story: ` +필터가 모두 비활성화된 초기 상태. + +각 칩을 클릭하면 해당 드로어가 열리고, 선택 후 칩이 활성 상태(초록색)로 바뀐다. +활성 칩의 X 버튼을 클릭하면 해당 필터가 초기화된다. + `, + }, + }, + }, +}; + +export const WithActiveFilters: Story = { + args: { + initialTags: ['성수동', '카페'], + initialEmotions: ['행복', '재미'], + initialDateRange: { start: '2026-01-01', end: '2026-01-31' }, + }, + parameters: { + docs: { + description: { + story: '태그·감정·날짜 필터가 모두 적용된 상태 — 각 칩이 활성화되어 선택값이 표시된다.', + }, + }, + }, +}; + +export const EmotionFilterDrawer: Story = { + name: 'EmotionDrawer (검색 모드)', + render: () => { + const [emotions, setEmotions] = useState([]); + const [open, setOpen] = useState(false); + return ( +
+ 0 ? emotions.join(', ') : '감정'} + isActive={emotions.length > 0} + onClick={() => setOpen(true)} + onClear={() => setEmotions([])} + /> + + setEmotions((prev) => + prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e], + ) + } + onReset={() => setEmotions([])} + onClose={() => setOpen(false)} + mode="search" + /> +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +검색 필터용 감정 드로어 — 에디터의 단일 선택과 달리 **여러 감정을 동시에 선택**할 수 있다. + +- **감정 클릭**: 토글 선택 (여러 개 선택 가능) +- **초기화 버튼**: 선택된 감정 전체 해제 +- **확인 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const DateRangeFilterDrawer: Story = { + name: 'DateDrawer (기간 선택 모드)', + render: () => { + const [dateRange, setDateRange] = useState<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }); + const [open, setOpen] = useState(false); + return ( +
+ setOpen(true)} + onClear={() => setDateRange({ start: null, end: null })} + /> + {open && ( + { setDateRange(r); setOpen(false); }} + onClose={() => setOpen(false)} + /> + )} +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +검색 필터용 날짜 기간 드로어 — 시작일과 종료일을 선택해 기간 필터를 설정한다. + +- **날짜 클릭**: 첫 번째 클릭은 시작일, 두 번째는 종료일 설정 +- **← / → 버튼**: 월 이동 +- **↺ 버튼**: 기간 초기화 +- **완료 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; diff --git a/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx b/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx new file mode 100644 index 00000000..f2729957 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/SearchItem.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import SearchItem from '../SearchItem'; +import { RecordSearchItem } from '@/lib/types/record'; + +const mockRecord: RecordSearchItem = { + id: 'record-1', + title: '성수동 카페 투어', + address: '서울 성동구 성수동2가', + date: '2026-01-15T10:00:00Z', + content: '오늘 성수동에서 카페를 여러 곳 방문했다. 분위기도 좋고 커피도 맛있었다.', + thumbnailMediaId: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + snippet: '오늘 성수동에서 카페를 여러 곳 방문했다.', +}; + +const mockRecordNoThumbnail: RecordSearchItem = { + id: 'record-2', + title: '한강 산책', + address: '서울 영등포구 여의도동', + date: '2026-01-10T14:00:00Z', + content: '오랜만에 한강에서 산책을 했다. 날씨가 맑고 상쾌했다.', + thumbnailMediaId: '', + snippet: '오랜만에 한강에서 산책을 했다.', +}; + +const mockRecordMinimal: RecordSearchItem = { + id: 'record-3', + title: '짧은 메모', + address: '', + date: '2026-01-05T08:00:00Z', + content: '간단한 메모만 남긴 기록.', + thumbnailMediaId: '', +}; + +const noop = () => {}; + +const meta = { + title: 'Search/SearchItem', + component: SearchItem, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +검색 페이지(\`/search\`)에서 검색 결과 목록의 각 기록 아이템을 표시하는 컴포넌트. + +썸네일 이미지(있는 경우), 제목, 스니펫, 날짜, 위치 정보를 카드 형태로 표시한다. +클릭 시 \`onClick(id)\`가 호출되어 기록 상세 페이지로 이동한다. + +- **thumbnailMediaId**: 값이 있으면 썸네일 이미지 표시, 없으면 이미지 아이콘으로 대체 +- **snippet**: 검색 키워드가 포함된 본문 발췌문 +- **address**: 위치 정보가 없으면 위치 영역 숨김 + `, + }, + }, + }, + tags: ['autodocs'], + args: { onClick: noop }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { record: mockRecord }, + parameters: { + docs: { + description: { + story: ` +썸네일·스니펫·주소가 모두 있는 기본 검색 결과 아이템. + +- **카드 클릭**: \`onClick(record.id)\` 호출 → 기록 상세 페이지(\`/record/:id\`)로 이동 +- **priorityLoad**: 첫 번째 결과에 true를 설정해 이미지 우선 로드 가능 + `, + }, + }, + }, +}; + +export const NoThumbnail: Story = { + args: { record: mockRecordNoThumbnail }, + parameters: { + docs: { + description: { + story: '썸네일 이미지가 없는 경우 — 이미지 자리에 이미지 아이콘이 표시된다.', + }, + }, + }, +}; + +export const Minimal: Story = { + args: { record: mockRecordMinimal }, + parameters: { + docs: { + description: { + story: '스니펫·주소가 없는 최소 구성의 기록 아이템 — 제목과 날짜만 표시된다.', + }, + }, + }, +}; + +export const ResultList: Story = { + render: (args) => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: '실제 검색 결과 목록처럼 여러 아이템이 나열된 상태.', + }, + }, + }, +}; diff --git a/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx b/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx new file mode 100644 index 00000000..e9341a53 --- /dev/null +++ b/frontend/src/app/(search)/_components/storybook/TagSearchDrawer.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; +import TagSearchDrawer from '../TagSearchDrawer'; +import { createMockTagStats } from '@/lib/mocks/mock'; + +function makeClient(tags?: ReturnType) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + if (tags) { + client.setQueryData(['profile', 'tags', 'summary', 10], tags); + } + return client; +} + +const clients = { + withTags: makeClient(createMockTagStats()), + noTags: makeClient({ recentTags: [], frequentTags: [] }), + withSelected: makeClient(createMockTagStats()), +}; + +function TagSearchDrawerWrapper({ + initialTags = [], + hasTags = true, +}: { + initialTags?: string[]; + hasTags?: boolean; +}) { + const [open, setOpen] = useState(false); + const [selectedTags, setSelectedTags] = useState(initialTags); + const client = hasTags ? (initialTags.length ? clients.withSelected : clients.withTags) : clients.noTags; + + if (!open) { + return ( +
+
+ {selectedTags.length > 0 + ? `선택된 태그: ${selectedTags.map((t) => `#${t}`).join(', ')}` + : '선택된 태그 없음'} +
+ +
+ ); + } + + return ( + + + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ) + } + onReset={() => setSelectedTags([])} + onClose={() => setOpen(false)} + /> + + ); +} + +const meta = { + title: 'Search/TagSearchDrawer', + component: TagSearchDrawerWrapper, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +검색 페이지의 태그 필터 칩을 클릭하면 열리는 드로어 컴포넌트. + +직접 태그를 입력하거나 자주 사용한 태그 목록에서 클릭해 여러 태그를 선택할 수 있다. +Enter 키 또는 + 버튼으로 입력한 태그를 추가하고, 선택된 태그는 상단에 배지로 표시된다. + +- **onToggleTag**: 태그 선택/해제 시 호출 +- **onReset**: 초기화 버튼 클릭 시 전체 선택 해제 +- **onClose**: 완료 또는 드로어 닫기 시 호출 + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { initialTags: [], hasTags: true }, + parameters: { + docs: { + description: { + story: ` +태그가 선택되지 않은 초기 상태. + +- **"태그 검색 드로어 열기" 버튼** 클릭 시 드로어 표시 +- **태그 입력 후 Enter 또는 + 버튼**: 태그 추가 +- **자주 사용한 태그 클릭**: 태그 선택/해제 토글 +- **초기화 버튼**: 선택된 태그 전체 해제 +- **완료 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const NoFrequentTags: Story = { + args: { initialTags: [], hasTags: false }, + parameters: { + docs: { + description: { + story: '자주 사용한 태그가 없는 경우 — "아직 사용한 태그가 없어요" 안내 메시지가 표시된다.', + }, + }, + }, +}; From dd83dfa0754d85d6ea154393180f69a7c4a72b6b Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 20:57:59 +0900 Subject: [PATCH 24/39] =?UTF-8?q?test:=20=EC=A7=80=EB=8F=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 탐색 결과 기록 아이템 목록 뷰 - 지도 탐색 결과 기록 drawer 기본/선택된 포스트/로딩/ 빈 목록 상태에 대한 뷰 --- .../storybook/MapRecordItem.stories.tsx | 139 ++++++++++++++ .../storybook/RecordMapDrawer.stories.tsx | 170 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 frontend/src/app/(map)/_components/storybook/MapRecordItem.stories.tsx create mode 100644 frontend/src/app/(map)/_components/storybook/RecordMapDrawer.stories.tsx diff --git a/frontend/src/app/(map)/_components/storybook/MapRecordItem.stories.tsx b/frontend/src/app/(map)/_components/storybook/MapRecordItem.stories.tsx new file mode 100644 index 00000000..1c4f7677 --- /dev/null +++ b/frontend/src/app/(map)/_components/storybook/MapRecordItem.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { MapRecordItem } from '../MapRecordItem'; +import { MapPostItem } from '@/lib/types/record'; + +const mockPost: MapPostItem = { + id: 'post-1', + lat: 37.5326, + lng: 126.9905, + title: '한강공원 피크닉', + thumbnailMediaId: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + createdAt: '2026-01-15T14:30:00Z', + tags: ['산책', '한강', '겨울'], + placeName: '한강공원 여의도지구', +}; + +const mockPostNoThumbnail: MapPostItem = { + id: 'post-2', + lat: 37.5665, + lng: 126.9780, + title: '광화문 광장 방문', + thumbnailMediaId: '', + createdAt: '2026-01-10T11:00:00Z', + tags: ['서울', '역사'], + placeName: '광화문 광장', +}; + +const mockPostNoPlace: MapPostItem = { + id: 'post-3', + lat: 37.5444, + lng: 127.0557, + title: '성수동 카페 탐방', + thumbnailMediaId: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + createdAt: '2026-01-08T09:00:00Z', + tags: ['카페', '성수동', '브런치', '주말'], + placeName: null, +}; + +const meta = { + title: 'Map/MapRecordItem', + component: MapRecordItem, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +지도 페이지(\`/map\`) 하단 드로어의 기록 목록에서 각 기록 아이템을 표시하는 컴포넌트. + +썸네일 이미지, 제목, 위치명, 날짜, 태그를 카드 형태로 표시한다. +지도 마커 클릭 시 해당 아이템이 하이라이트(isHighlighted=true) 상태로 강조된다. +우측 화살표 버튼 클릭 시 기록 상세 페이지로 이동한다. + +- **isHighlighted**: 지도에서 선택된 마커와 연결된 기록인 경우 \`true\` — 초록색 테두리와 강조 스타일 적용 +- **onSelect**: 카드 클릭 시 호출 — 지도에서 해당 마커 포커스 +- **onNavigate**: 화살표 버튼 클릭 시 호출 — 기록 상세 페이지로 이동 + `, + }, + }, + }, + tags: ['autodocs'], + args: { + onSelect: () => {}, + onNavigate: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + post: mockPost, + isHighlighted: false, + }, + parameters: { + docs: { + description: { + story: ` +기본 상태의 기록 아이템 — 썸네일·위치·태그가 있는 일반적인 기록 카드. + +- **카드 클릭**: \`onSelect\` 호출 → 지도에서 해당 마커 강조 +- **화살표(→) 버튼 클릭**: \`onNavigate\` 호출 → 기록 상세 페이지 이동 +- **isHighlighted 변경** (Controls 패널): true로 설정하면 초록색 하이라이트 스타일 적용 + `, + }, + }, + }, +}; + +export const Highlighted: Story = { + args: { + post: mockPost, + isHighlighted: true, + }, + parameters: { + docs: { + description: { + story: '지도에서 해당 마커가 선택된 상태 — 초록색 테두리·배경·링 효과로 강조된다.', + }, + }, + }, +}; + +export const NoThumbnail: Story = { + args: { + post: mockPostNoThumbnail, + isHighlighted: false, + }, + parameters: { + docs: { + description: { + story: '썸네일 이미지가 없는 기록 — 이미지 영역이 사라지고 텍스트 영역이 좌측으로 확장된다.', + }, + }, + }, +}; + +export const ItemList: Story = { + render: (args) => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: '지도 드로어 목록 실제 렌더링 예시 — 첫 번째 아이템이 선택(하이라이트)된 상태.', + }, + }, + }, +}; diff --git a/frontend/src/app/(map)/_components/storybook/RecordMapDrawer.stories.tsx b/frontend/src/app/(map)/_components/storybook/RecordMapDrawer.stories.tsx new file mode 100644 index 00000000..4719e3fa --- /dev/null +++ b/frontend/src/app/(map)/_components/storybook/RecordMapDrawer.stories.tsx @@ -0,0 +1,170 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import RecordMapDrawer from '../RecordMapDrawer'; +import { MapPostItem } from '@/lib/types/record'; + +const mockPosts: MapPostItem[] = [ + { + id: 'post-1', + lat: 37.5326, + lng: 126.9905, + title: '한강공원 피크닉', + thumbnailMediaId: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + createdAt: '2026-01-15T14:30:00Z', + tags: ['산책', '한강', '겨울'], + placeName: '한강공원 여의도지구', + }, + { + id: 'post-2', + lat: 37.5665, + lng: 126.9780, + title: '광화문 광장 역사 탐방', + thumbnailMediaId: '', + createdAt: '2026-01-10T11:00:00Z', + tags: ['서울', '역사', '문화'], + placeName: '광화문 광장', + }, + { + id: 'post-3', + lat: 37.5444, + lng: 127.0557, + title: '성수동 카페 투어', + thumbnailMediaId: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + createdAt: '2026-01-08T09:00:00Z', + tags: ['카페', '성수동'], + placeName: '성수동 카페거리', + }, + { + id: 'post-4', + lat: 37.5172, + lng: 127.0473, + title: '강남 맛집 탐방', + thumbnailMediaId: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', + createdAt: '2026-01-05T18:00:00Z', + tags: ['맛집', '강남'], + placeName: '강남역 근처', + }, +]; + +function RecordMapDrawerWrapper({ + posts, + isLoading, + initialSelectedId, +}: { + posts: MapPostItem[]; + isLoading: boolean; + initialSelectedId?: string | null; +}) { + const [selectedPostId, setSelectedPostId] = useState( + initialSelectedId ?? null, + ); + + return ( +
+
+ 지도 영역 (Storybook에서 Google Maps 미표시) +
+ +
+ ); +} + +const meta = { + title: 'Map/RecordMapDrawer', + component: RecordMapDrawerWrapper, + parameters: { + layout: 'padded', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +지도 페이지(\`/map\`) 하단에 고정된 바텀 시트 드로어 컴포넌트. + +지도 주변의 기록 목록을 스크롤 가능한 카드 목록으로 표시하며, 상단 핸들 드래그로 높이를 조절할 수 있다. +지도 마커를 클릭하면 해당 기록이 하이라이트되고 드로어가 반쯤 올라온다. +지도 빈 영역 클릭 시 선택이 해제되고 드로어가 닫힌다. + +- **selectedPostId**: 현재 선택된 기록 ID — 해당 카드를 자동 스크롤 후 하이라이트 +- **isLoading**: 데이터 로딩 중일 때 스피너 표시 +- **posts**: 표시할 기록 목록; 빈 배열이면 "주변에 기록이 없어요" 안내 + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + posts: mockPosts, + isLoading: false, + initialSelectedId: null, + }, + parameters: { + docs: { + description: { + story: ` +기록 4개가 있는 기본 상태 — 드로어가 최소 높이로 시작한다. + +- **상단 핸들 드래그**: 드로어 높이 조절 (축소/반절/전체) +- **카드 클릭**: 해당 기록 선택 → 드로어 반절 높이로 팝업 +- **배경 클릭**: 선택 해제 → 드로어 축소 +- **화살표(→) 버튼**: 기록 상세 페이지로 이동 + `, + }, + }, + }, +}; + +export const WithSelectedPost: Story = { + args: { + posts: mockPosts, + isLoading: false, + initialSelectedId: 'post-1', + }, + parameters: { + docs: { + description: { + story: '첫 번째 기록이 선택된 상태로 시작 — 해당 카드가 초록색으로 하이라이트되고 드로어가 반절 높이로 열린다.', + }, + }, + }, +}; + +export const Loading: Story = { + args: { + posts: [], + isLoading: true, + initialSelectedId: null, + }, + parameters: { + docs: { + description: { + story: '기록 데이터를 로딩 중인 상태 — 스피너가 표시된다.', + }, + }, + }, +}; + +export const Empty: Story = { + args: { + posts: [], + isLoading: false, + initialSelectedId: null, + }, + parameters: { + docs: { + description: { + story: '현재 지도 영역에 기록이 없는 상태 — "주변에 기록이 없어요" 안내 메시지와 아이콘이 표시된다.', + }, + }, + }, +}; From be3778a91a706909c0e63e4ad6a7951f5b2488ac Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 20:59:08 +0900 Subject: [PATCH 25/39] =?UTF-8?q?test:=20=EA=B8=B0=EB=A1=9D=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기록 작성 페이지 헤더의 작성/수정 모드 - 기록 작성 페이지 협업 멤버 뱃지 표현 - 전체 블록 타입의 빈 상태와 데이터가 있는 상태 비교 --- .../storybook/RecordEditorDrawers.stories.tsx | 495 ++++++++++++++++ .../storybook/RecordEditorHeader.stories.tsx | 156 +++++ .../editor/storybook/RecordFields.stories.tsx | 539 ++++++++++++++++++ .../editor/storybook/Toolbar.stories.tsx | 64 +++ 4 files changed, 1254 insertions(+) create mode 100644 frontend/src/app/(post)/_components/editor/storybook/RecordEditorDrawers.stories.tsx create mode 100644 frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx create mode 100644 frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx create mode 100644 frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx diff --git a/frontend/src/app/(post)/_components/editor/storybook/RecordEditorDrawers.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorDrawers.stories.tsx new file mode 100644 index 00000000..a1d7df45 --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorDrawers.stories.tsx @@ -0,0 +1,495 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import EmotionDrawer from '../emotion/EmotionDrawer'; +import TagDrawer from '../tag/TagDrawer'; +import RatingDrawer from '../rating/RatingPickerDrawer'; +import TimePickerDrawer from '../core/TimePickerDrawer'; +import PhotoDrawer from '../photo/PhotoDrawer'; +import MediaDrawer from '../media/MediaDrawer'; +import DateDrawer from '@/components/DateDrawer'; + +import type { MoodValue, RatingValue, TagValue, TimeValue, ImageValue } from '@/lib/types/record'; +import type { MediaValue } from '@/lib/types/recordField'; + +const noop = () => {}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +// ─── MSW 핸들러 공통 ──────────────────────────────────────────────────────── +const mockTagsHandler = http.get('/api/stats/tags', () => + HttpResponse.json({ + success: true, + data: { + recentTags: [ + { tag: '아침', count: 3 }, + { tag: '커피', count: 7 }, + ], + frequentTags: [ + { tag: '팀프로젝트', count: 12 }, + { tag: '커피', count: 7 }, + { tag: '점심', count: 5 }, + { tag: '아침', count: 3 }, + { tag: '팀 잇다', count: 1 }, + ], + }, + }), +); + +const mockMediaResolveHandler = http.post('/api/media/resolve', () => + HttpResponse.json({ success: true, data: { items: [], failed: [] } }), +); + +const mockMovieSearchHandler = http.get('/api/tmdb/search/movie', () => + HttpResponse.json({ + results: [ + { + id: 157336, + title: '인터스텔라', + original_title: 'Interstellar', + poster_path: null, + release_date: '2014-11-05', + }, + { + id: 603, + title: '매트릭스', + original_title: 'The Matrix', + poster_path: null, + release_date: '1999-03-31', + }, + { + id: 27205, + title: '인셉션', + original_title: 'Inception', + poster_path: null, + release_date: '2010-07-16', + }, + ], + }), +); + +// ─── Trigger 버튼 래퍼 헬퍼 ───────────────────────────────────────────────── +function DrawerTrigger({ + label, + children, +}: { + label: string; + children: (open: boolean, setOpen: (v: boolean) => void) => React.ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( +
+ + {children(open, setOpen)} +
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'RecordEditor/RecordEditorDrawers', + component: EmotionDrawer, + parameters: { + layout: 'centered', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +기록 에디터에서 각 블록 필드를 클릭하거나 툴바에서 블록을 추가할 때 열리는 드로어 모음. + +각 스토리의 버튼을 클릭하면 실제 드로어가 열린다. + +| 드로어 | 트리거 | +|--------|--------| +| \`EmotionDrawer\` | 감정 블록 클릭 | +| \`TagDrawer\` | 태그 블록 클릭 | +| \`RatingDrawer\` | 별점 블록 클릭 | +| \`TimePickerDrawer\` | 날짜·시간 블록 클릭 | +| \`DateDrawer\` | 날짜 블록 클릭 | +| \`PhotoDrawer\` | 사진 블록 클릭 | +| \`MediaDrawer\` | 미디어 블록 클릭 | + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── EmotionDrawer ──────────────────────────────────────────────────────────── +export const Emotion: Story = { + name: 'EmotionDrawer', + render: () => { + const [selected, setSelected] = useState({ mood: '' }); + return ( + + {(open, setOpen) => ( + setSelected({ mood })} + onClose={() => setOpen(false)} + /> + )} + + ); + }, + parameters: { + docs: { + description: { + story: ` +감정 블록을 클릭했을 때 열리는 드로어 — 5열 그리드로 19가지 감정 이모지를 선택할 수 있다. + +- **감정 클릭**: 해당 감정 선택 (링 강조 표시) +- **확인 버튼**: 드로어 닫기 + `, + }, + }, + }, +}; + +export const EmotionWithSelected: Story = { + name: 'EmotionDrawer — 선택된 상태', + render: () => { + const [selected, setSelected] = useState({ mood: '행복' }); + return ( + + {(open, setOpen) => ( + setSelected({ mood })} + onClose={() => setOpen(false)} + /> + )} + + ); + }, + parameters: { + docs: { + description: { + story: '이미 "행복"이 선택된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── TagDrawer ──────────────────────────────────────────────────────────────── +export const Tag: Story = { + name: 'TagDrawer', + render: () => { + const [tags, setTags] = useState({ tags: [] }); + return ( + + {(open, setOpen) => + open && ( + setTags({ tags: newTags })} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockTagsHandler] }, + docs: { + description: { + story: ` +태그 블록을 클릭했을 때 열리는 드로어 — 직접 입력하거나 이전에 사용한 태그를 선택할 수 있다. + +- **# 입력창 + Enter / + 버튼**: 태그 추가 (최대 4개) +- **이전 사용 태그 클릭**: 토글 선택 +- **4개 초과 시**: 경고 메시지 표시 + `, + }, + }, + }, +}; + +export const TagWithExisting: Story = { + name: 'TagDrawer — 기존 태그 있음', + render: () => { + const [tags, setTags] = useState({ tags: ['성수동', '카페'] }); + return ( + + {(open, setOpen) => + open && ( + setTags({ tags: newTags })} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockTagsHandler] }, + docs: { + description: { + story: '이미 태그 2개가 선택된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── RatingDrawer ───────────────────────────────────────────────────────────── +export const Rating: Story = { + name: 'RatingDrawer', + render: () => { + const [rating, setRating] = useState({ rating: 0 }); + return ( + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +별점 블록을 클릭했을 때 열리는 드로어 — 별 위를 드래그하거나 클릭해 0.1 단위로 별점을 설정한다. + +- **별 영역 드래그/클릭**: 0.1점 단위 별점 입력 +- **숫자 표시**: 현재 선택된 점수가 큰 숫자로 실시간 표시 +- **확인 버튼**: 별점 저장 후 드로어 닫기 + `, + }, + }, + }, +}; + +export const RatingWithValue: Story = { + name: 'RatingDrawer — 3.5점 상태', + render: () => { + const [rating, setRating] = useState({ rating: 3.5 }); + return ( + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: '3.5점이 미리 입력된 상태로 드로어가 열린다.', + }, + }, + }, +}; + +// ─── DateDrawer ─────────────────────────────────────────────────────────────── +export const DateSingle: Story = { + name: 'DateDrawer — 날짜 선택', + render: () => { + const [date, setDate] = useState('2026-01-15'); + return ( + + {(open, setOpen) => + open && ( + { setDate(d); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +날짜 블록을 클릭했을 때 열리는 달력 드로어. + +- **날짜 클릭**: 해당 날짜 선택 후 드로어 닫기 +- **← / → 버튼**: 월 이동 +- **↺ 버튼**: 오늘 날짜로 초기화 + `, + }, + }, + }, +}; + +// ─── TimePickerDrawer ───────────────────────────────────────────────────────── +export const TimePicker: Story = { + name: 'TimePickerDrawer', + render: () => { + const [time, setTime] = useState({ time: '14:30' }); + return ( + + {(open, setOpen) => + open && ( + { setTime({ time: t }); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + docs: { + description: { + story: ` +시간 블록을 클릭했을 때 열리는 드럼롤 타임피커 드로어. + +- **스크롤 / 드래그**: 시·분 선택 +- **직접 입력 모드**: 키보드 아이콘 클릭 시 텍스트 입력으로 전환 +- **확인 버튼**: 시간 저장 후 드로어 닫기 + `, + }, + }, + }, +}; + +// ─── PhotoDrawer ────────────────────────────────────────────────────────────── +export const Photo: Story = { + name: 'PhotoDrawer — 사진 있음', + render: () => { + const [photos, setPhotos] = useState({ + mediaIds: [], + tempUrls: [ + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + ], + }); + return ( + + + {(open, setOpen) => + open && ( + + setPhotos((prev) => ({ + ...prev, + tempUrls: prev.tempUrls?.filter((_, i) => i !== idx), + })) + } + onRemoveAll={() => setPhotos({ mediaIds: [], tempUrls: [] })} + onClose={() => setOpen(false)} + /> + ) + } + + + ); + }, + parameters: { + msw: { handlers: [mockMediaResolveHandler] }, + docs: { + description: { + story: ` +사진 블록을 클릭했을 때 열리는 드로어 — 추가된 사진 목록과 관리 기능을 제공한다. + +- **+ 버튼**: 사진 추가 (업로드 플로우) +- **사진 우측 X**: 해당 사진 개별 삭제 +- **전체 삭제 버튼**: 모든 사진 제거 +- **메타데이터 수정**: 촬영 날짜·시간·위치를 기록 블록에 자동 반영 + `, + }, + }, + }, +}; + +export const PhotoEmpty: Story = { + name: 'PhotoDrawer — 사진 없음', + render: () => ( + + + {(open, setOpen) => + open && ( + setOpen(false)} + /> + ) + } + + + ), + parameters: { + msw: { handlers: [mockMediaResolveHandler] }, + docs: { + description: { + story: '사진이 한 장도 없는 초기 상태 — "+ 사진 추가" 버튼만 표시된다.', + }, + }, + }, +}; + +// ─── MediaDrawer ────────────────────────────────────────────────────────────── +export const Media: Story = { + name: 'MediaDrawer', + render: () => { + const [, setSelected] = useState(null); + return ( + + {(open, setOpen) => + open && ( + { setSelected(media); setOpen(false); }} + onClose={() => setOpen(false)} + /> + ) + } + + ); + }, + parameters: { + msw: { handlers: [mockMovieSearchHandler] }, + docs: { + description: { + story: ` +미디어 블록을 클릭했을 때 열리는 드로어 — 영화·연극·뮤지컬을 검색해 기록에 첨부한다. + +- **카테고리 칩**: 영화 / 연극 / 뮤지컬 전환 +- **검색창 입력**: 자동으로 검색 결과 표시 (MSW mock) +- **검색 결과 클릭**: 해당 미디어 선택 후 드로어 닫기 +- **직접 입력**: 검색 결과에 없는 경우 수동으로 제목·종류·연도 입력 + `, + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx new file mode 100644 index 00000000..fdd42c85 --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/RecordEditorHeader.stories.tsx @@ -0,0 +1,156 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import RecordEditorHeader from '../RecordEditorHeader'; +import { PresenceMember } from '@/hooks/useDraftPresence'; + +const mockMembers: PresenceMember[] = [ + { + actorId: 'actor-1', + sessionId: 'session-1', + displayName: '도비', + permissionRole: 'EDITOR', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-2', + sessionId: 'session-2', + displayName: '루피', + permissionRole: 'EDITOR', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-3', + sessionId: 'session-3', + displayName: '하니', + permissionRole: 'VIEWER', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, +]; + +const manyMembers: PresenceMember[] = [ + ...mockMembers, + { + actorId: 'actor-4', + sessionId: 'session-4', + displayName: '새벽', + permissionRole: 'EDITOR', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-5', + sessionId: 'session-5', + displayName: '민영', + permissionRole: 'EDITOR', + profileImageId: '/profile-ex.jpeg', + lastSeenAt: new Date().toISOString(), + }, + { + actorId: 'actor-6', + sessionId: 'session-6', + displayName: '수연', + permissionRole: 'ADMIN', + profileImageId: null, + lastSeenAt: new Date().toISOString(), + }, +]; + +const meta = { + title: 'RecordEditor/RecordEditorHeader', + component: RecordEditorHeader, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +기록 작성·수정 페이지 상단에 고정되는 헤더 컴포넌트. + +좌측에 뒤로가기 버튼과 페이지 제목(기록 작성/기록 수정), 우측에 현재 편집 중인 멤버 아바타 목록과 저장 버튼이 표시된다. +멤버 아바타에 마우스를 올리면 편집 중인 멤버 목록 팝오버가 나타난다. + +- **mode**: \`'add'\` — "기록 작성" / \`'edit'\` — "기록 수정" +- **members**: 실시간 협업 중인 멤버 목록 (presence 데이터) — 빈 배열이면 멤버 표시 영역 숨김 +- **onSave**: 우측 저장 버튼 클릭 시 호출 + `, + }, + }, + }, + tags: ['autodocs'], + args: { + onSave: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: 'add', + members: [], + }, + parameters: { + docs: { + description: { + story: ` +기록 작성 모드 — 협업 멤버 없이 혼자 작성하는 기본 상태. + +- **← 뒤로가기 버튼**: 이전 페이지로 이동 +- **저장 버튼**: \`onSave\` 호출 → 기록 저장 처리 + `, + }, + }, + }, +}; + +export const EditMode: Story = { + args: { + mode: 'edit', + members: [], + }, + parameters: { + docs: { + description: { + story: '기록 수정 모드 — 헤더 제목이 "기록 수정"으로 변경된다.', + }, + }, + }, +}; + +export const WithCollaborators: Story = { + args: { + mode: 'edit', + members: mockMembers, + }, + parameters: { + docs: { + description: { + story: '3명이 실시간으로 함께 편집 중인 상태 — 멤버 아바타가 우측에 표시된다. 아바타에 마우스를 올리면 멤버 목록 팝오버가 나타난다.', + }, + }, + }, +}; + +export const ManyCollaborators: Story = { + args: { + mode: 'edit', + members: manyMembers, + }, + parameters: { + docs: { + description: { + story: '5명 초과(6명)가 편집 중인 상태 — 최대 4명의 아바타가 표시되고 나머지는 "+N" 숫자 배지로 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx new file mode 100644 index 00000000..d766658f --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/RecordFields.stories.tsx @@ -0,0 +1,539 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { DateField, TimeField, ContentField } from '../core/CoreField'; +import { EmotionField } from '../emotion/EmotionField'; +import { PhotoField } from '../photo/PhotoField'; +import { TagField } from '../tag/TagField'; +import { RatingField } from '../rating/RatingField'; +import { TableField } from '../table/TableField'; +import MediaField from '../media/MediaField'; +import { LocationField } from '@/components/map/LocationField'; + +import type { + DateValue, + TimeValue, + TextValue, + MoodValue, + TagValue, + RatingValue, + LocationValue, + ImageValue, + TableValue, + MediaInfoValue, +} from '@/lib/types/record'; + +const noop = () => {}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +// ─── 비어 있는 기본값 ─────────────────────────────────────────────────────── +const emptyDate: DateValue = { date: '' }; +const emptyTime: TimeValue = { time: '' }; +const emptyText: TextValue = { text: '' }; +const emptyEmotion: MoodValue = { mood: '' }; +const emptyTags: TagValue = { tags: [] }; +const emptyRating: RatingValue = { rating: 0 }; +const emptyLocation: LocationValue = { lat: 0, lng: 0, address: '' }; +const emptyPhoto: ImageValue = { mediaIds: [], tempUrls: [] }; +const emptyTable: TableValue = { + rows: 2, + cols: 2, + cells: [ + ['', ''], + ['', ''], + ], +}; +const emptyMedia: MediaInfoValue = { + title: '', + type: '', + externalId: '', + year: '', + imageUrl: '', +}; + +// ─── 데이터 있는 값 ────────────────────────────────────────────────────────── +const filledDate: DateValue = { date: '2026-01-15' }; +const filledTime: TimeValue = { time: '14:30' }; +const filledText: TextValue = { + text: '성수동 카페에서 오랜만에 친구와 만났다. 날씨도 좋고 커피도 맛있었다.', +}; +const filledEmotion: MoodValue = { mood: '행복' }; +const filledTags: TagValue = { tags: ['성수동', '카페', '친구'] }; +const filledRating: RatingValue = { rating: 4 }; +const filledLocation: LocationValue = { + lat: 37.5444, + lng: 127.0557, + address: '서울 성동구 성수동2가', + placeName: '성수 카페거리', +}; +const filledPhoto: ImageValue = { + mediaIds: [], + tempUrls: [ + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + 'https://images.unsplash.com/photo-1516715094483-75da7dee9758?auto=format&fit=crop&q=80&w=400', + ], +}; +const filledTable: TableValue = { + rows: 3, + cols: 2, + cells: [ + ['항목', '내용'], + ['이름', '성수 카페'], + ['가격', '7,000원'], + ], +}; +const filledMedia: MediaInfoValue = { + title: '인터스텔라', + type: '영화', + externalId: 'tt0816692', + year: '2014', + imageUrl: + 'https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&q=80&w=56', +}; + +// ─── 레이아웃 래퍼 ─────────────────────────────────────────────────────────── +function FieldSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function EditorCanvas({ children }: { children: React.ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} + +// ─── Meta ──────────────────────────────────────────────────────────────────── +const meta = { + title: 'RecordEditor/RecordFields', + parameters: { + layout: 'padded', + nextjs: { appDirectory: true }, + msw: { + handlers: [ + // PhotoField 내부의 useMediaResolveMulti가 빈 배열로 호출할 때 빈 결과 반환 + http.post('/api/media/resolve', () => + HttpResponse.json({ success: true, data: { items: [], failed: [] } }), + ), + ], + }, + docs: { + description: { + component: ` +기록 작성·수정 에디터에서 툴바 버튼을 클릭하면 추가되는 블록 필드 컴포넌트 모음. + +각 블록은 **빈 상태(방금 추가됨)**와 **데이터 있는 상태** 두 가지로 표시된다. +- 빈 상태: 점선 테두리의 플레이스홀더 버튼 → 클릭하면 해당 드로어/입력 열림 +- 데이터 있는 상태: 입력된 값이 카드/칩 형태로 표시, 우측 X로 삭제 가능 + +| 툴바 아이콘 | 블록 타입 | 컴포넌트 | +|------------|-----------|---------| +| T | 텍스트 | \`ContentField\` | +| 사진 | 이미지 | \`PhotoField\` | +| 이모지 | 감정 | \`EmotionField\` | +| 태그 | 태그 | \`TagField\` | +| 위치 | 위치 | \`LocationField\` | +| 표 | 테이블 | \`TableField\` | +| 별 | 별점 | \`RatingField\` | +| 돋보기 | 미디어 정보 | \`MediaField\` | + +날짜/시간 블록은 툴바가 아닌 기록 생성 시 자동으로 추가된다. + `, + }, + }, + }, + tags: ['autodocs'], + // component는 단일 컴포넌트가 아니므로 대표용으로 ContentField 지정 + component: ContentField, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ───────────────────────────────────────────────────────────────── + +export const AllEmpty: Story = { + render: () => ( + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: ` +툴바에서 각 버튼을 클릭했을 때 **처음 추가되는 빈 상태**. + +점선 테두리의 플레이스홀더 버튼이 표시되며, 버튼 클릭 시 해당 입력 드로어가 열린다. +우측 X 버튼으로 해당 블록을 에디터에서 제거할 수 있다. + +- **날짜·시간**: 툴바가 아닌 기록 생성 시 현재 날짜·시간으로 자동 삽입됨 +- **텍스트**: 좌측 초록색 세로선이 포커스 시 나타남 +- **테이블**: 빈 셀부터 시작해 열/행 추가 가능 + `, + }, + }, + }, +}; + +export const AllFilled: Story = { + render: () => ( + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: ` +데이터가 입력된 상태. 플레이스홀더 대신 실제 값이 카드·칩 형태로 표시된다. + +- **사진**: 최대 3장이 겹쳐 보이고 초과 수는 "+N"으로 표시됨 +- **태그**: 최대 4개까지 추가 가능하며, 4개 미만이면 + 버튼으로 더 추가 +- **미디어**: 포스터 이미지·제목·종류·연도가 카드로 표시됨 + `, + }, + }, + }, +}; + +export const DateTimeField: Story = { + render: () => ( + + +
+ + +
+
+ +
+ + +
+
+
+ ), + parameters: { + docs: { + description: { + story: '날짜·시간 필드 — 기록 생성 시 현재 시각으로 자동 채워지며, 클릭하면 타임피커 드로어가 열린다.', + }, + }, + }, +}; + +export const TextField: Story = { + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '텍스트 블록 — 여러 개 추가할 수 있으며, 마지막 텍스트 블록에는 삭제(X) 버튼이 숨겨진다.', + }, + }, + }, +}; + +export const PhotoFieldStory: Story = { + name: 'PhotoField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '사진 블록 — 최대 3장이 겹쳐 표시되고 초과분은 "+N"으로 표시된다. 클릭하면 사진 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const EmotionFieldStory: Story = { + name: 'EmotionField', + render: () => ( + + + + +
+ {(['행복', '좋음', '만족', '재미', '피곤', '화남', '슬픔', '보통'] as const).map( + (mood) => ( + + + + ), + )} +
+
+ ), + parameters: { + docs: { + description: { + story: '감정 블록 — 선택된 감정에 맞는 이모지와 이름이 표시된다. 클릭하면 감정 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const TagFieldStory: Story = { + name: 'TagField', + render: () => ( + + + + + + + + + = 4 ? filledTags : { tags: ['성수동', '카페', '친구', '주말'] }} onRemove={noop} onAdd={noop} onRemoveField={noop} /> + + + ), + parameters: { + docs: { + description: { + story: '태그 블록 — 최대 4개까지 추가 가능. 4개 미만이면 + 버튼이 표시되어 태그 추가 드로어를 열 수 있다.', + }, + }, + }, +}; + +export const RatingFieldStory: Story = { + name: 'RatingField', + render: () => ( + + + + +
+ {([1, 2, 3, 4, 5] as const).map((rating) => ( + + + + ))} +
+
+ ), + parameters: { + docs: { + description: { + story: '별점 블록 — 1~5점 범위. 클릭하면 별점 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const LocationFieldStory: Story = { + name: 'LocationField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '위치 블록 — 장소명이 있으면 장소명을, 없으면 주소를 표시한다. 클릭하면 지도 위치 선택 화면으로 이동한다.', + }, + }, + }, +}; + +export const MediaFieldStory: Story = { + name: 'MediaField', + render: () => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '미디어 정보 블록 (영화·연극 등) — 포스터 이미지·제목·종류·연도가 카드로 표시된다. 이미지가 없으면 필름 아이콘으로 대체된다.', + }, + }, + }, +}; + +export const TableFieldStory: Story = { + name: 'TableField', + render: () => ( + + + + + + + + + ), + parameters: { + docs: { + description: { + story: '테이블 블록 — 셀을 직접 편집할 수 있으며 최대 4×4까지 행/열을 추가할 수 있다.', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx b/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx new file mode 100644 index 00000000..62db65a6 --- /dev/null +++ b/frontend/src/app/(post)/_components/editor/storybook/Toolbar.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import Toolbar from '../Toolbar'; + +const meta = { + title: 'RecordEditor/Toolbar', + component: Toolbar, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +기록 작성·수정 페이지 하단에 고정되는 툴바 컴포넌트. + +기록에 추가할 수 있는 블록 타입 버튼들이 나열된다. +각 버튼을 클릭하면 해당 타입의 새 블록이 에디터에 추가된다. + +| 아이콘 | 블록 타입 | 설명 | +|--------|-----------|------| +| T (텍스트) | \`content\` | 텍스트 블록 | +| 사진 | \`photos\` | 이미지 블록 | +| 이모지 | \`emotion\` | 감정 블록 | +| 태그 | \`tags\` | 태그 블록 | +| 위치 | \`location\` | 위치 블록 | +| 표 | \`table\` | 테이블 블록 | +| 별 | \`rating\` | 별점 블록 | +| 돋보기 | \`media\` | 미디어 검색 블록 | + `, + }, + }, + }, + tags: ['autodocs'], + args: { + onAddBlock: () => {}, + onOpenDrawer: () => {}, + }, + decorators: [ + (Story) => ( +
+
+ 에디터 콘텐츠 영역 +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + parameters: { + docs: { + description: { + story: ` +기록 에디터 하단 툴바 기본 상태. + +- **각 아이콘 버튼 클릭**: \`onAddBlock(type)\` 호출 → 에디터에 해당 타입 블록 추가 +- 아이콘에 호버 시 초록색(\`itta-point\`)으로 강조됨 + `, + }, + }, + }, +}; From 85ad650022f84f825668fef485d54a17af40ee91 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 21:15:14 +0900 Subject: [PATCH 26/39] =?UTF-8?q?test:=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 글로벌 헤더 스토리 추가 - 하단 네비게이션바 개인/그룹별 스토리 추가 --- .../storybook/BottomNavigation.stories.tsx | 163 ++++++++++++++++++ .../components/storybook/Header.stories.tsx | 124 +++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 frontend/src/components/storybook/BottomNavigation.stories.tsx create mode 100644 frontend/src/components/storybook/Header.stories.tsx diff --git a/frontend/src/components/storybook/BottomNavigation.stories.tsx b/frontend/src/components/storybook/BottomNavigation.stories.tsx new file mode 100644 index 00000000..16d427fa --- /dev/null +++ b/frontend/src/components/storybook/BottomNavigation.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import BottomNavigation from '../BottomNavigation'; +import { createMockGroupList } from '@/lib/mocks/mock'; + +const GROUP_ID = 'group-123'; + +function makeClient(role?: 'ADMIN' | 'EDITOR' | 'VIEWER') { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // /shared 경로에서 그룹 목록을 prefetch하므로 미리 주입 + client.setQueryData(['shared'], createMockGroupList().items); + // 그룹 네비 스토리에서 role 주입 + if (role) { + client.setQueryData(['group', GROUP_ID, 'me', 'role'], { role }); + } + return client; +} + +const clients = { + personal: makeClient(), + groupEditor: makeClient('EDITOR'), + groupViewer: makeClient('VIEWER'), +}; + +function NavWrapper({ + children, + client, +}: { + children: React.ReactNode; + client: QueryClient; +}) { + return ( + +
+

페이지 콘텐츠 영역

+ {children} +
+
+ ); +} + +const meta = { + title: 'Layout/BottomNavigation', + component: BottomNavigation, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + story: { height: '220px' }, + description: { + component: ` +모바일 화면 하단에 고정되는 네비게이션 바. + +현재 경로에 따라 **개인 네비**와 **그룹 네비** 두 가지 형태로 전환된다. +일부 경로(\`/add\`, \`/search\` 등)에서는 숨겨진다. + +**개인 네비** (기본): 홈 · 기록함 · + · 그룹함 · 지도 + +**그룹 네비** (\`/group/:id\` 경로): 그룹홈 · 지도 · + · 알림 · 나가기 + +- **+ 버튼**: 개인 — 기록 작성(\`/add\`) 이동 · 그룹함(\`/shared\`)에서는 그룹 선택 드로어 +- **+ 버튼 (그룹)**: EDITOR 이상만 활성화, VIEWER는 비활성(회색) + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── 개인 네비게이션 ─────────────────────────────────────────────────────────── + +export const PersonalHome: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/' } }, + docs: { + description: { story: '개인 네비 — 홈 탭 활성 상태.' }, + }, + }, +}; + +export const PersonalRecords: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/my' } }, + docs: { + description: { story: '개인 네비 — 기록함 탭 활성 상태.' }, + }, + }, +}; + +export const PersonalShared: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/shared' } }, + docs: { + description: { + story: '개인 네비 — 그룹함 탭 활성 상태. + 버튼이 초록색으로 변경되며 클릭 시 그룹 선택 드로어가 열린다.', + }, + }, + }, +}; + +export const PersonalMap: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: '/map' } }, + docs: { + description: { story: '개인 네비 — 지도 탭 활성 상태.' }, + }, + }, +}; + +// ─── 그룹 네비게이션 ─────────────────────────────────────────────────────────── + +export const GroupEditor: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + docs: { + description: { + story: '그룹 네비 — EDITOR 권한. + 버튼이 활성화되어 그룹 기록 작성 드로어를 열 수 있다.', + }, + }, + }, +}; + +export const GroupViewer: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + docs: { + description: { + story: '그룹 네비 — VIEWER 권한. + 버튼이 회색으로 비활성화된다.', + }, + }, + }, +}; + +export const GroupMap: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}/map` } }, + docs: { + description: { story: '그룹 네비 — 지도 탭 활성 상태.' }, + }, + }, +}; + +export const GroupNotifications: Story = { + decorators: [(Story) => ], + parameters: { + nextjs: { navigation: { pathname: `/group/${GROUP_ID}/notifications` } }, + docs: { + description: { story: '그룹 네비 — 알림 탭 활성 상태.' }, + }, + }, +}; diff --git a/frontend/src/components/storybook/Header.stories.tsx b/frontend/src/components/storybook/Header.stories.tsx new file mode 100644 index 00000000..7a1ec681 --- /dev/null +++ b/frontend/src/components/storybook/Header.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import Header from '../Header'; +import { UserProfileResponse } from '@/lib/types/profileResponse'; + +const mockProfileBase: UserProfileResponse = { + userId: 'user-1', + user: { + id: 'user-1', + email: 'hello@example.com', + nickname: '도비', + profileImageId: null, + provider: 'google', + providerId: 'google-123', + profileImage: undefined, + settings: {}, + createdAt: '2024-01-01T00:00:00Z', + }, + stats: { + recentTags: ['카페', '산책'], + frequentTags: ['카페'], + recentEmotions: [], + frequentEmotions: [], + totalPosts: 42, + totalImages: 15, + frequentLocations: [], + monthlyCounts: [], + }, +}; + +const PROFILE_IMAGE_URL = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=80'; + +const mockProfileWithImage: UserProfileResponse = { + ...mockProfileBase, + user: { + ...mockProfileBase.user, + profileImageId: PROFILE_IMAGE_URL, + profileImage: { url: PROFILE_IMAGE_URL }, + }, +}; + +function makeClient(profile: UserProfileResponse) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['profile', 'me'], profile); + return client; +} + +const clients = { + noImage: makeClient(mockProfileBase), + withImage: makeClient(mockProfileWithImage), +}; + +const meta = { + title: 'Layout/Header', + component: Header, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +모든 주요 페이지 상단에 고정되는 헤더 컴포넌트. + +좌측 "잇다-" 로고 클릭 시 홈으로 이동하고, +우측에 검색 아이콘과 프로필 이미지(원형) 버튼이 표시된다. + +- **검색 아이콘**: \`/search\` 페이지로 이동 +- **프로필 아이콘**: \`/profile\` 페이지로 이동 +- 프로필 이미지가 없으면 기본 이미지(\`profile_base.png\`)가 표시된다 + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { + story: ` +프로필 이미지가 없는 기본 상태 — 기본 이미지로 표시된다. + +- **"잇다-" 클릭**: 홈(\`/\`)으로 이동 +- **돋보기 클릭**: 검색(\`/search\`)으로 이동 +- **프로필 아이콘 클릭**: 프로필(\`/profile\`)로 이동 + `, + }, + }, + }, +}; + +export const WithProfileImage: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + docs: { + description: { + story: '프로필 이미지가 있는 상태 — 우측 아이콘에 실제 프로필 사진이 표시된다.', + }, + }, + }, +}; From 80a9f268987f4c6c86d5c871e49f14612703da08 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 21:15:56 +0900 Subject: [PATCH 27/39] =?UTF-8?q?test:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공지 목록(이미지/텍스트/혼합), 빈 상태에 대한 스토리 추가 - 홈 진입시 표시되는 공지 팝업 모달 스토리 추가 --- .../storybook/AnnouncementsPage.stories.tsx | 218 ++++++++++++++++++ .../storybook/AnnouncementModal.stories.tsx | 112 +++++++++ 2 files changed, 330 insertions(+) create mode 100644 frontend/src/app/(main)/announcements/storybook/AnnouncementsPage.stories.tsx create mode 100644 frontend/src/components/storybook/AnnouncementModal.stories.tsx diff --git a/frontend/src/app/(main)/announcements/storybook/AnnouncementsPage.stories.tsx b/frontend/src/app/(main)/announcements/storybook/AnnouncementsPage.stories.tsx new file mode 100644 index 00000000..c51b05c4 --- /dev/null +++ b/frontend/src/app/(main)/announcements/storybook/AnnouncementsPage.stories.tsx @@ -0,0 +1,218 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import AssetImage from '@/components/AssetImage'; + +interface Announcement { + id: string; + title: string; + content?: string | null; + imageUrl?: string | null; + isActive: boolean; + createdAt: string; +} + +function formatKoreanDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function AnnouncementCard({ announcement }: { announcement: Announcement }) { + return ( +
+ {announcement.imageUrl && ( +
+ +
+ )} +
+ {announcement.isActive && ( + + 진행 중 + + )} +

+ {announcement.title} +

+
+ {announcement.content && ( +

+ {announcement.content} +

+ )} +

+ {formatKoreanDate(announcement.createdAt)} +

+
+ ); +} + +function AnnouncementsPage({ announcements }: { announcements: Announcement[] }) { + return ( +
+
+
+

+ 공지사항 +

+
+
+ +
+ {announcements.length === 0 ? ( +
+

공지사항이 없습니다.

+
+ ) : ( + announcements.map((announcement) => ( + + )) + )} +
+
+ ); +} + +// ─── Mock 데이터 ────────────────────────────────────────────────────────────── +const mockAnnouncements: Announcement[] = [ + { + id: '1', + title: '[업데이트] 공동 기록 실시간 협업 기능 출시', + content: + '그룹 기록을 여러 명이 동시에 편집할 수 있는 실시간 협업 기능이 추가되었습니다.\n함께 기억을 남겨보세요!', + imageUrl: + 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&q=80&w=896', + isActive: true, + createdAt: '2026-01-15T10:00:00Z', + }, + { + id: '2', + title: '[공지] 서비스 점검 안내 (1/20 02:00 ~ 04:00)', + content: + '서버 안정화 작업으로 인해 서비스 점검이 진행됩니다.\n점검 시간 동안 서비스 이용이 일시 중단됩니다.', + imageUrl: null, + isActive: false, + createdAt: '2026-01-10T09:00:00Z', + }, + { + id: '3', + title: '[이벤트] 잇다- 베타 출시 기념 이벤트', + content: '베타 기간 동안 피드백을 남겨주신 분들께 특별한 혜택을 드립니다.', + imageUrl: null, + isActive: false, + createdAt: '2025-12-01T00:00:00Z', + }, +]; + +// ─── Meta ───────────────────────────────────────────────────────────────────── +const meta = { + title: 'Pages/AnnouncementsPage', + component: AnnouncementsPage, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + docs: { + description: { + component: ` +프로필 페이지에서 접근할 수 있는 공지사항 목록 페이지. + +서버에서 공지사항을 불러와 카드 형태로 나열한다. + +- **이미지**: 공지에 이미지가 있으면 카드 상단에 배너로 표시 +- **진행 중 배지**: \`isActive: true\`인 공지에만 초록색 배지 표시 +- **빈 상태**: 공지가 없으면 "공지사항이 없습니다." 안내 표시 + +> 실제 페이지는 RSC로 서버에서 데이터를 fetch하며, 스토리에서는 mock 데이터로 동일한 UI를 확인한다. + `, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ────────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: { announcements: mockAnnouncements }, + parameters: { + docs: { + description: { + story: ` +이미지 있는 진행 중 공지, 이미지 없는 공지, 종료된 공지가 섞인 일반적인 목록 상태. + +- **"진행 중" 배지**: 첫 번째 카드에만 표시 +- **이미지 배너**: 첫 번째 카드에만 표시 + `, + }, + }, + }, +}; + +export const Empty: Story = { + args: { announcements: [] }, + parameters: { + docs: { + description: { + story: '공지사항이 없는 경우 — "공지사항이 없습니다." 안내 문구가 중앙에 표시된다.', + }, + }, + }, +}; + +export const WithImageOnly: Story = { + args: { + announcements: [ + { + id: '1', + title: '[업데이트] 공동 기록 실시간 협업 기능 출시', + content: '그룹 기록을 여러 명이 동시에 편집할 수 있는 실시간 협업 기능이 추가되었습니다.', + imageUrl: + 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&q=80&w=896', + isActive: true, + createdAt: '2026-01-15T10:00:00Z', + }, + ], + }, + parameters: { + docs: { + description: { + story: '이미지와 "진행 중" 배지가 모두 있는 단일 공지 — 카드 상단에 배너 이미지가 표시된다.', + }, + }, + }, +}; + +export const TextOnly: Story = { + args: { + announcements: [ + { + id: '2', + title: '[공지] 서비스 점검 안내 (1/20 02:00 ~ 04:00)', + content: + '서버 안정화 작업으로 인해 서비스 점검이 진행됩니다.\n점검 시간 동안 서비스 이용이 일시 중단됩니다.', + imageUrl: null, + isActive: false, + createdAt: '2026-01-10T09:00:00Z', + }, + ], + }, + parameters: { + docs: { + description: { + story: '이미지 없이 텍스트만 있는 공지 — 제목·내용·날짜만 표시된다.', + }, + }, + }, +}; diff --git a/frontend/src/components/storybook/AnnouncementModal.stories.tsx b/frontend/src/components/storybook/AnnouncementModal.stories.tsx new file mode 100644 index 00000000..94939572 --- /dev/null +++ b/frontend/src/components/storybook/AnnouncementModal.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AnnouncementModalClient from '../AnnouncementModalClient'; +import type { AnnouncementData } from '../AnnouncementModal'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const mockHandlers = [ + http.patch('/api/me/settings', () => HttpResponse.json({ success: true })), +]; + +const withImageAnnouncement: AnnouncementData = { + id: 'ann-1', + title: '[업데이트] 공동 기록 실시간 협업 기능 출시', + content: + '그룹 기록을 여러 명이 동시에 편집할 수 있는 실시간 협업 기능이 추가되었습니다.\n함께 기억을 남겨보세요!', + imageUrl: + 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&q=80&w=600', +}; + +const textOnlyAnnouncement: AnnouncementData = { + id: 'ann-2', + title: '[공지] 서비스 점검 안내 (1/20 02:00 ~ 04:00)', + content: + '서버 안정화 작업으로 인해 서비스 점검이 진행됩니다.\n점검 시간 동안 서비스 이용이 일시 중단됩니다.', + imageUrl: null, +}; + +const titleOnlyAnnouncement: AnnouncementData = { + id: 'ann-3', + title: '[이벤트] 잇다- 베타 출시 기념 이벤트가 시작되었습니다!', + content: null, + imageUrl: null, +}; + +const meta = { + title: 'Pages/AnnouncementModal', + component: AnnouncementModalClient, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true }, + msw: { handlers: mockHandlers }, + docs: { + description: { + component: ` +홈 페이지 진입 시 활성 공지사항이 있으면 자동으로 표시되는 모달 컴포넌트. + +배경 오버레이 위에 공지 카드가 중앙 팝업으로 나타나며, X 버튼 또는 "확인" 버튼으로 닫을 수 있다. +닫으면 해당 공지 ID를 서버 설정(로그인) 또는 쿠키(게스트)에 저장해 재표시하지 않는다. + +- **imageUrl**: 이미지가 있으면 카드 상단에 16:9 비율로 표시 +- **content**: 내용이 없으면 제목만 표시 +- **X 버튼 / 확인 버튼 / 배경 클릭**: 모두 닫기 동작 + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+
홈 페이지 콘텐츠 영역
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { announcement: withImageAnnouncement }, + parameters: { + docs: { + description: { + story: ` +이미지와 내용이 모두 있는 공지 모달 — 가장 일반적인 형태. + +- **배경 클릭 / X 버튼 / 확인 버튼**: 모달 닫기 → API로 dismissed 상태 저장 + `, + }, + }, + }, +}; + +export const TextOnly: Story = { + args: { announcement: textOnlyAnnouncement }, + parameters: { + docs: { + description: { + story: '이미지 없이 제목·내용만 있는 공지 모달.', + }, + }, + }, +}; + +export const TitleOnly: Story = { + args: { announcement: titleOnlyAnnouncement }, + parameters: { + docs: { + description: { + story: '제목만 있고 내용이 없는 공지 모달 — 제목과 확인 버튼만 표시된다.', + }, + }, + }, +}; From ff892360ede44a03bad815a0b4bcfac0cbc143d6 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 21:57:07 +0900 Subject: [PATCH 28/39] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=95=84=ED=84=B0=20end=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EB=8B=B9=EC=9D=BC=20=EA=B8=B0=EB=A1=9D=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YYYY-MM-DD를 PostgreSQL이 자정 00:00으로 해석해 당일 오후 생성 기록이 eventAt <= 00:00 조건에서 제외되는 버그 수정 - end를 23:59:59로 정규화 - 기간 필터에서 오늘 기록이 포함되는지 검증하는 회귀 테스트 추가 --- backend/src/modules/map/map.service.ts | 17 +- frontend/e2e/map.spec.ts | 270 +++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 frontend/e2e/map.spec.ts diff --git a/backend/src/modules/map/map.service.ts b/backend/src/modules/map/map.service.ts index 2ba46a7f..6df8a5e9 100644 --- a/backend/src/modules/map/map.service.ts +++ b/backend/src/modules/map/map.service.ts @@ -6,6 +6,7 @@ import { Post } from '@/modules/post/entity/post.entity'; import { PostBlock } from '@/modules/post/entity/post-block.entity'; import { PostBlockType } from '@/enums/post-block-type.enum'; import { BlockValueMap } from '@/modules/post/types/post-block.types'; +import { DateTime } from 'luxon'; import { MapPostsQueryDto, PaginatedMapPostsResponseDto, @@ -70,10 +71,14 @@ export class MapService { // Filters if (from) { - query.andWhere('post.eventAt >= :from', { from }); + query.andWhere('post.eventAt >= :from', { + from: this.normalizeDateBoundary(from, 'start'), + }); } if (to) { - query.andWhere('post.eventAt <= :to', { to }); + query.andWhere('post.eventAt <= :to', { + to: this.normalizeDateBoundary(to, 'end'), + }); } if (tags) { const tagList = tags.split(',').map((t) => t.trim()); @@ -195,4 +200,12 @@ export class MapService { const value = `${eventAt.toISOString()}|${id}`; return Buffer.from(value).toString('base64'); } + + // 날짜만 있는 문자열(YYYY-MM-DD)을 하루의 시작/끝 시각으로 정규화 + // search.service.ts의 normalizeDateOnlyBoundary와 동일한 방식 + private normalizeDateBoundary(value: string, boundary: 'start' | 'end'): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + const dt = DateTime.fromISO(value, { zone: 'Asia/Seoul' }); + return (boundary === 'start' ? dt.startOf('day') : dt.endOf('day')).toISO() ?? value; + } } diff --git a/frontend/e2e/map.spec.ts b/frontend/e2e/map.spec.ts new file mode 100644 index 00000000..4c998a89 --- /dev/null +++ b/frontend/e2e/map.spec.ts @@ -0,0 +1,270 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { createTestRecord, deleteTestRecord } from './fixtures/api'; + +// 기본 지도 중심 좌표(경복궁) — DEFAULT_BOUNDS_DELTA(0.1) 범위 내에 위치해 초기 API 요청에서 조회됨 +const MAP_LAT = 37.5796; +const MAP_LNG = 126.977; +const RECORD_TITLE = 'E2E 지도 테스트 기록'; + +// 필터 검증용 상수 +const FILTER_TAG = 'E2EMapFilter태그'; +const FILTER_EMOTION = '행복'; +const MAIN_TITLE = 'E2E 지도 필터 기록 (태그+감정)'; // 필터 조건 포함 +const CONTROL_TITLE = 'E2E 지도 대조 기록 (위치만)'; // 필터 조건 없음 +const YESTERDAY_TITLE = 'E2E 지도 어제 기록'; // 어제 날짜 (날짜 필터 대조용) + +const TODAY = new Date(); +const TODAY_STR = TODAY.toISOString().split('T')[0]; +const YESTERDAY = new Date(TODAY); +YESTERDAY.setDate(TODAY.getDate() - 1); +const YESTERDAY_STR = YESTERDAY.toISOString().split('T')[0]; +const TWO_DAYS_AGO = new Date(TODAY); +TWO_DAYS_AGO.setDate(TODAY.getDate() - 2); +const TWO_DAYS_AGO_STR = TWO_DAYS_AGO.toISOString().split('T')[0]; + +const LOCATION_BLOCK = { + type: 'LOCATION', + value: { + lat: MAP_LAT, + lng: MAP_LNG, + address: '서울특별시 종로구 사직로 161', + placeName: '경복궁', + }, + layout: { row: 3, col: 1, span: 2 }, +}; + +async function gotoMap(page: import('@playwright/test').Page) { + await page.goto('/map'); + await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); +} + +test.describe('지도', () => { + let postId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '.auth/guest.json'), + }); + const page = await ctx.newPage(); + const record = await createTestRecord(page, RECORD_TITLE, undefined, { + extraBlocks: [ + { + type: 'LOCATION', + value: { + lat: MAP_LAT, + lng: MAP_LNG, + address: '서울특별시 종로구 사직로 161', + placeName: '경복궁', + }, + layout: { row: 3, col: 1, span: 2 }, + }, + ], + }); + postId = record.id; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '.auth/guest.json'), + }); + const page = await ctx.newPage(); + await deleteTestRecord(page, postId); + await ctx.close(); + }); + + test('지도 페이지가 로드된다', async ({ page }) => { + await page.goto('/map'); + await expect(page).toHaveURL('/map'); + await expect(page.locator('body')).toBeVisible(); + }); + + test('장소 검색 바와 필터 칩이 표시된다', async ({ page }) => { + await gotoMap(page); + await expect(page.getByPlaceholder('장소를 검색하세요')).toBeVisible({ timeout: 8000 }); + await expect(page.getByRole('button', { name: '태그' })).toBeVisible(); + await expect(page.getByRole('button', { name: '감정' })).toBeVisible(); + await expect(page.getByRole('button', { name: '날짜' })).toBeVisible(); + }); + + test('하단 드로어에 주변 기록 목록이 표시된다', async ({ page }) => { + await gotoMap(page); + await expect(page.getByText(RECORD_TITLE).first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/주변 기록 \d+개/)).toBeVisible({ timeout: 8000 }); + }); + + test('기록 아이템의 → 버튼을 클릭하면 기록 상세 페이지로 이동한다', async ({ page }) => { + await gotoMap(page); + const recordItem = page.locator(`[data-post-id="${postId}"]`); + await expect(recordItem).toBeVisible({ timeout: 10000 }); + + // MapRecordItem의 navigate 버튼(ChevronRight, stopPropagation 처리된 버튼) + const navigateBtn = recordItem.getByRole('button'); + await navigateBtn.click(); + + await expect(page).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 8000 }); + }); + + test('태그 필터 칩을 클릭하면 태그 검색 드로어가 열린다', async ({ page }) => { + await gotoMap(page); + await page.getByRole('button', { name: '태그' }).click(); + await expect(page.getByText('여러 태그로 검색')).toBeVisible({ timeout: 5000 }); + }); + + test('감정 필터 칩을 클릭하면 감정 선택 드로어가 열린다', async ({ page }) => { + await gotoMap(page); + await page.getByRole('button', { name: '감정' }).click(); + await expect(page.getByText('감정으로 검색')).toBeVisible({ timeout: 5000 }); + }); + + test('날짜 필터 칩을 클릭하면 날짜 기간 선택 드로어가 열린다', async ({ page }) => { + await gotoMap(page); + await page.getByRole('button', { name: '날짜' }).click(); + await expect(page.getByText('기간 선택')).toBeVisible({ timeout: 5000 }); + }); + + test('URL에 태그 필터가 있으면 칩이 활성화 상태로 표시된다', async ({ page }) => { + await page.goto('/map?tags=E2E태그'); + await expect(page.getByRole('button', { name: /E2E태그/ })).toBeVisible({ timeout: 8000 }); + }); + + test('URL에 감정 필터가 있으면 칩이 활성화 상태로 표시된다', async ({ page }) => { + await page.goto('/map?emotions=행복'); + await expect(page.getByRole('button', { name: /행복/ })).toBeVisible({ timeout: 8000 }); + }); +}); + +test.describe('지도 필터', () => { + let mainId: string; + let controlId: string; + let yesterdayId: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '.auth/guest.json'), + }); + const page = await ctx.newPage(); + + // MAIN: 태그 + 감정 + 위치, 오늘 날짜 (태그·감정·날짜 필터 조건 충족) + const main = await createTestRecord(page, MAIN_TITLE, TODAY_STR, { + extraBlocks: [ + LOCATION_BLOCK, + { type: 'TAG', value: { tags: [FILTER_TAG] }, layout: { row: 4, col: 1, span: 2 } }, + { type: 'MOOD', value: { mood: FILTER_EMOTION }, layout: { row: 5, col: 1, span: 2 } }, + ], + }); + mainId = main.id; + + // CONTROL: 위치만 포함, 오늘 날짜 (태그·감정 필터 조건 없음) + const control = await createTestRecord(page, CONTROL_TITLE, TODAY_STR, { + extraBlocks: [LOCATION_BLOCK], + }); + controlId = control.id; + + // YESTERDAY: 위치 포함, 어제 날짜 (날짜 필터 대조용) + const yesterday = await createTestRecord(page, YESTERDAY_TITLE, YESTERDAY_STR, { + extraBlocks: [LOCATION_BLOCK], + }); + yesterdayId = yesterday.id; + + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '.auth/guest.json'), + }); + const page = await ctx.newPage(); + await deleteTestRecord(page, mainId); + await deleteTestRecord(page, controlId); + await deleteTestRecord(page, yesterdayId); + await ctx.close(); + }); + + test('필터 미적용 시 조건 기록과 대조 기록이 모두 드로어에 표시된다', async ({ page }) => { + await gotoMap(page); + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(CONTROL_TITLE).first()).toBeVisible({ timeout: 8000 }); + }); + + test('태그 필터 적용 시 해당 태그를 가진 기록만 표시된다', async ({ page }) => { + await page.goto(`/map?tags=${encodeURIComponent(FILTER_TAG)}`); + + // MAIN: 해당 태그 포함 → 드로어에 표시 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 태그 없음 → 드로어에 표시되지 않음 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('감정 필터 적용 시 해당 감정을 가진 기록만 표시된다', async ({ page }) => { + await page.goto(`/map?emotions=${encodeURIComponent(FILTER_EMOTION)}`); + + // MAIN: 해당 감정 포함 → 드로어에 표시 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 감정 없음 → 드로어에 표시되지 않음 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('태그+감정 복합 필터 적용 시 두 조건을 모두 만족하는 기록만 표시된다', async ({ page }) => { + await page.goto( + `/map?tags=${encodeURIComponent(FILTER_TAG)}&emotions=${encodeURIComponent(FILTER_EMOTION)}`, + ); + + // MAIN: 두 조건 모두 충족 → 표시 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 두 조건 모두 미충족 → 표시되지 않음 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + }); + + test('오늘 날짜 필터 적용 시 오늘 기록만 표시된다', async ({ page }) => { + await page.goto(`/map?start=${TODAY_STR}&end=${TODAY_STR}`); + + // MAIN·CONTROL: 오늘 날짜 → 표시 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(CONTROL_TITLE).first()).toBeVisible({ timeout: 8000 }); + // YESTERDAY: 어제 날짜 → 표시되지 않음 + if (TODAY.getDate() > 1) { + await expect(page.getByText(YESTERDAY_TITLE)).not.toBeVisible(); + } + }); + + test('어제 날짜 필터 적용 시 어제 기록만 표시된다', async ({ page }) => { + await page.goto(`/map?start=${YESTERDAY_STR}&end=${YESTERDAY_STR}`); + + // YESTERDAY: 어제 날짜 → 표시 + await expect(page.getByText(YESTERDAY_TITLE).first()).toBeVisible({ timeout: 10000 }); + // MAIN·CONTROL: 오늘 날짜 → 표시되지 않음 + if (TODAY.getDate() > 1) { + await expect(page.getByText(MAIN_TITLE)).not.toBeVisible(); + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + } + }); + + test('기간 날짜 필터(이틀 전~오늘)에서 오늘 생성한 기록이 포함된다', async ({ page }) => { + // 버그 재현: to=TODAY 를 '오늘 00:00'으로 해석하면 오늘 오후 기록이 제외됨 + // 수정 후: normalizeDateBoundary가 to를 '오늘 23:59:59'로 정규화 + await page.goto(`/map?start=${TWO_DAYS_AGO_STR}&end=${TODAY_STR}`); + + // MAIN·CONTROL: 오늘 날짜 → 기간 내 포함 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(CONTROL_TITLE).first()).toBeVisible({ timeout: 8000 }); + // YESTERDAY: 어제 날짜 → 기간 내 포함 + await expect(page.getByText(YESTERDAY_TITLE).first()).toBeVisible({ timeout: 8000 }); + }); + + test('태그+날짜 복합 필터 적용 시 두 조건을 모두 만족하는 기록만 표시된다', async ({ page }) => { + await page.goto( + `/map?tags=${encodeURIComponent(FILTER_TAG)}&start=${TODAY_STR}&end=${TODAY_STR}`, + ); + + // MAIN: 태그 있음 + 오늘 날짜 → 표시 + await expect(page.getByText(MAIN_TITLE).first()).toBeVisible({ timeout: 10000 }); + // CONTROL: 태그 없음 → 표시되지 않음 + await expect(page.getByText(CONTROL_TITLE)).not.toBeVisible(); + // YESTERDAY: 어제 날짜 → 표시되지 않음 + if (TODAY.getDate() > 1) { + await expect(page.getByText(YESTERDAY_TITLE)).not.toBeVisible(); + } + }); +}); From 1d51cc24496b0cd3c9b0eced2993fbce103b3502 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 21:58:23 +0900 Subject: [PATCH 29/39] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 존재하지 않는 검색어로 검색 시 빈 상태 메시지 표시 확인 - URL ?q= 파라미터로 직접 진입시 검색어가 입력창에 복원되는지 확인 --- frontend/e2e/search.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts index fb6bef4c..c844f140 100644 --- a/frontend/e2e/search.spec.ts +++ b/frontend/e2e/search.spec.ts @@ -61,4 +61,28 @@ test.describe('검색', () => { await input.clear(); await expect(page.locator('body')).toBeVisible(); }); + + test('존재하지 않는 검색어로 검색하면 빈 상태 메시지가 표시된다', async ({ page }) => { + await page.goto('/search'); + const input = page.locator('input').first(); + + await Promise.all([ + page.waitForResponse( + (res) => res.url().includes('/api/search') && res.request().method() === 'POST', + { timeout: 10000 }, + ), + input.fill('E2E절대존재하지않는검색어xyzxyz'), + ]); + + // 결과가 없으면 빈 상태 UI가 표시된다 + await expect( + page.getByText(/검색 결과가 없|기록이 없|결과가 없/), + ).toBeVisible({ timeout: 8000 }); + }); + + test('URL 쿼리 파라미터 q로 진입하면 검색어가 입력창에 복원된다', async ({ page }) => { + await page.goto('/search?q=E2E검색'); + const input = page.locator('input').first(); + await expect(input).toHaveValue('E2E검색', { timeout: 5000 }); + }); }); From 77ed63750f3d1e6be144141b01096dca4b18168c Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Fri, 22 May 2026 21:59:15 +0900 Subject: [PATCH 30/39] =?UTF-8?q?test:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=EB=B0=8F=20=ED=99=88=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공지 목록의 제목, 날짜 포맷 검증 - 홈 진입시 활성 공지 팝업 표시 확인 - 확인 클릭 후 dismiss 쿠키 설정으로 재진입시 팝업 미표시 확인 --- frontend/e2e/announcement-modal.spec.ts | 71 +++++++++++++++++++++++++ frontend/e2e/announcements.spec.ts | 36 +++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 frontend/e2e/announcement-modal.spec.ts diff --git a/frontend/e2e/announcement-modal.spec.ts b/frontend/e2e/announcement-modal.spec.ts new file mode 100644 index 00000000..b655ba35 --- /dev/null +++ b/frontend/e2e/announcement-modal.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; + +// 백엔드 .env.local의 ADMIN_KEY (개발 환경 기본값) +const ADMIN_KEY = process.env.E2E_ADMIN_KEY ?? 'ittda_admin'; +const ANNOUNCEMENT_TITLE = 'E2E 테스트 공지사항'; + +// /api/admin/announcements → Next.js 프록시 → 백엔드 /v1/admin/announcements +async function createAnnouncement( + request: import('@playwright/test').APIRequestContext, +): Promise { + const res = await request.post('/api/admin/announcements', { + headers: { 'x-admin-key': ADMIN_KEY, 'Content-Type': 'application/json' }, + data: { + title: ANNOUNCEMENT_TITLE, + content: 'E2E 테스트 중 생성된 공지입니다.', + isActive: true, + }, + }); + const body = await res.json(); + if (!body.data?.id) throw new Error(`공지 생성 실패: ${JSON.stringify(body)}`); + return body.data.id; +} + +async function deleteAnnouncement( + request: import('@playwright/test').APIRequestContext, + id: string, +): Promise { + await request.delete(`/api/admin/announcements/${id}`, { + headers: { 'x-admin-key': ADMIN_KEY }, + }); +} + +test.describe('홈 공지사항 팝업', () => { + let announcementId: string; + + test.use({ storageState: path.join(__dirname, '.auth/guest.json') }); + + test.beforeAll(async ({ request }) => { + // isActive: true 생성 시 기존 활성 공지가 자동 비활성화됨 + announcementId = await createAnnouncement(request); + }); + + test.afterAll(async ({ request }) => { + await deleteAnnouncement(request, announcementId); + }); + + test('활성 공지사항이 있으면 홈 진입 시 팝업 모달이 표시된다', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByText(ANNOUNCEMENT_TITLE), + ).toBeVisible({ timeout: 8000 }); + // 확인 버튼도 함께 표시된다 + await expect(page.getByRole('button', { name: '확인' })).toBeVisible(); + }); + + test('"확인" 버튼을 누르면 모달이 닫히고 다시 홈에 와도 팝업이 뜨지 않는다', async ({ page }) => { + // 각 테스트는 test.use({ storageState })로 독립된 컨텍스트를 가지므로 + // dismiss 쿠키가 없는 초기 상태로 시작됨 + await page.goto('/'); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).toBeVisible({ timeout: 8000 }); + + // 2) 확인 버튼 클릭 → dismiss 쿠키 설정 + router.refresh() + await page.getByRole('button', { name: '확인' }).click(); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).not.toBeVisible({ timeout: 5000 }); + + // 3) 홈 재진입 → 쿠키 기반으로 SSR에서 모달 렌더링 제외 + await page.goto('/'); + await expect(page.getByText(ANNOUNCEMENT_TITLE)).not.toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/e2e/announcements.spec.ts b/frontend/e2e/announcements.spec.ts index c618b281..a946f765 100644 --- a/frontend/e2e/announcements.spec.ts +++ b/frontend/e2e/announcements.spec.ts @@ -35,4 +35,40 @@ test.describe('공지사항', () => { // /profile로 이동하거나 브라우저 히스토리의 이전 페이지로 이동 await expect(page).toHaveURL(/\/(profile|announcements)/, { timeout: 3000 }); }); + + test('공지사항이 있을 때 각 항목에 제목과 날짜가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + // 빈 상태면 건너뜀 + const isEmpty = await page + .getByText('공지사항이 없습니다.') + .isVisible({ timeout: 3000 }) + .catch(() => false); + if (isEmpty) { + test.skip(); + return; + } + + const firstCard = page.locator('main > div').first(); + // 제목 — h3 태그 + await expect(firstCard.locator('h3')).toBeVisible({ timeout: 8000 }); + await expect(firstCard.locator('h3')).not.toBeEmpty(); + // 날짜 — formatKoreanDate가 '년' 포함 + await expect(firstCard.getByText(/\d{4}년/)).toBeVisible(); + }); + + test('진행 중인 공지에는 "진행 중" 배지가 표시된다', async ({ page }) => { + await page.goto('/announcements'); + + const badge = page.getByText('진행 중'); + const hasBadge = await badge.isVisible({ timeout: 3000 }).catch(() => false); + + if (!hasBadge) { + // 현재 활성 공지가 없는 환경이면 건너뜀 + test.skip(); + return; + } + + await expect(badge.first()).toBeVisible(); + }); }); From 1945b3e2966e82b53010782e0af368001594ce69 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 8 Jun 2026 13:47:43 +0900 Subject: [PATCH 31/39] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그룹 삭제 버튼 비활성화 조건을 VIEWER에서 ADMIN이 아닐 때로 수정 - 멤버 내보내기/권한 변경 시 실명 대신 그룹 닉네임 표시 - role 로드 완료 후에만 초대 버튼을 렌더링해 VIEWER에게 버튼이 잠깐 보이는 깜빡임 방지 - 그룹 삭제 후 invalidate queryKey 오타 수정(share에서 shared로 변경) --- .../edit/_components/GroupDangerousZone.tsx | 2 +- .../_components/GroupMemberManagement.tsx | 20 ++++++++++--------- .../group/_components/GroupHeaderActions.tsx | 7 +++++-- .../group/_components/GroupInviteDrawer.tsx | 2 +- frontend/src/hooks/useGroupActions.ts | 7 ++----- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx index 92689717..e1d2f88f 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupDangerousZone.tsx @@ -37,7 +37,7 @@ export default function GroupDangerousZone({
diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx index 437d5df6..0a1d4a97 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/GroupMemberManagement.tsx @@ -38,7 +38,7 @@ const MemberCard = memo(function MemberCard({ member: GroupMember; me: GroupEditResponse['me']; onOpenRoleDrawer: (member: GroupMember) => void; - onConfirmRemove: (userId: string, name: string) => void; + onConfirmRemove: (userId: string, name: string, nicknameInGroup: string) => void; }) { const canManage = me.userId !== member.userId && me.role === 'ADMIN'; @@ -49,11 +49,11 @@ const MemberCard = memo(function MemberCard({ }, [canManage, onOpenRoleDrawer, member]); const handleRemoveClick = useCallback(() => { - onConfirmRemove(member.userId, member.name); - }, [onConfirmRemove, member.userId, member.name]); + onConfirmRemove(member.userId, member.name, member.nicknameInGroup || ''); + }, [onConfirmRemove, member.userId, member.name, member.nicknameInGroup]); return ( -
+
{member.profileImage?.assetId ? ( @@ -106,6 +106,7 @@ const MemberCard = memo(function MemberCard({ {canManage && (
- {`정말 '${deleteMember?.name}'님을 그룹에서 내보내시겠습니까?`} + {`정말 '${deleteMember?.nicknameInGroup || deleteMember?.name}'님을 그룹에서 내보내시겠습니까?`}
@@ -297,7 +299,7 @@ const GroupMemberManagement = memo(function GroupMemberManagement({ Set Permissions - {editingMember?.name}님의 권한 변경 + {editingMember?.nicknameInGroup || editingMember?.name}님의 권한 변경
diff --git a/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx b/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx index 0393b616..edbe7c5c 100644 --- a/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx +++ b/frontend/src/app/(post)/group/_components/GroupHeaderActions.tsx @@ -64,6 +64,9 @@ export default function GroupHeaderActions({ enabled: !!groupId, }); + // roleData가 undefined(로딩 중)일 때 false로 평가되면 VIEWER에게 초대 버튼이 잠깐 표시되는 문제가 있음. + // role이 확정된 후에만 버튼 가시성을 결정하기 위해 undefined 체크를 포함한다. + const roleLoaded = roleData !== undefined; const isViewer = roleData?.role === 'VIEWER'; const isAdmin = roleData?.role === 'ADMIN'; @@ -78,7 +81,7 @@ export default function GroupHeaderActions({ {groupInfo.groupName}
- {!isViewer && } + {roleLoaded && !isViewer && } - + diff --git a/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx b/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx index dde0a144..7879036a 100644 --- a/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx +++ b/frontend/src/app/(post)/group/_components/GroupInviteDrawer.tsx @@ -124,7 +124,7 @@ export default function GroupInviteDrawer({ groupId }: GroupInviteDrawerProps) { onOpenChange={setIsOpen} shouldScaleBackground={false} > - + diff --git a/frontend/src/hooks/useGroupActions.ts b/frontend/src/hooks/useGroupActions.ts index 20ba0b9f..766a417a 100644 --- a/frontend/src/hooks/useGroupActions.ts +++ b/frontend/src/hooks/useGroupActions.ts @@ -12,11 +12,8 @@ export const useDeleteGroup = (groupId: string, groupName: string) => { return useApiDelete(`/api/groups/${groupId}`, { onSuccess: () => { toast.success(`${groupName}이 삭제되었습니다.`); - queryClient.invalidateQueries({ queryKey: ['share'] }); - - setTimeout(() => { - router.push('/shared'); - }, 1000); + queryClient.invalidateQueries({ queryKey: ['shared'] }); + router.push('/shared'); }, }); }; From 013e2e1eb97f8755a0c8907cb0559b3ea018d6fd Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 8 Jun 2026 13:49:59 +0900 Subject: [PATCH 32/39] =?UTF-8?q?test:=20=EA=B7=B8=EB=A3=B9=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그룹 나가기, 멤버 프로필 변경, 역할 변경, 그룹 설정 조회 등 API 헬퍼 함수 추가 - 그룹 삭제 플로우 e2e 테스트 추가 - 그룹 나가기 테스트 추가 멤버 역할 변경, 내보내기 테스트 추가 - 그룹 내 프로필 변경 테스트 추가 - 회원 탈퇴 플로우 테스트 추가 --- frontend/e2e/fixtures/api.ts | 76 +++++ frontend/e2e/group-delete.spec.ts | 122 ++++++++ frontend/e2e/group-leave.spec.ts | 244 +++++++++++++++ frontend/e2e/group-management.spec.ts | 363 ++++++++++++++++++++++ frontend/e2e/group-member-profile.spec.ts | 105 +++++++ frontend/e2e/user-withdrawal.spec.ts | 188 +++++++++++ 6 files changed, 1098 insertions(+) create mode 100644 frontend/e2e/group-delete.spec.ts create mode 100644 frontend/e2e/group-leave.spec.ts create mode 100644 frontend/e2e/group-management.spec.ts create mode 100644 frontend/e2e/group-member-profile.spec.ts create mode 100644 frontend/e2e/user-withdrawal.spec.ts diff --git a/frontend/e2e/fixtures/api.ts b/frontend/e2e/fixtures/api.ts index 85c68967..6b34cb40 100644 --- a/frontend/e2e/fixtures/api.ts +++ b/frontend/e2e/fixtures/api.ts @@ -204,3 +204,79 @@ export async function deleteTestGroup( const headers = await getAuthHeaders(page); await page.request.delete(`/api/groups/${groupId}`, { headers }); } + +/** + * 그룹에서 나간다 (DELETE /api/groups/{groupId}/members/me). + */ +export async function leaveTestGroup(page: Page, groupId: string): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.delete(`/api/groups/${groupId}/members/me`, { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 나가기 실패: ${JSON.stringify(body.error)}`); +} + +/** + * 그룹 내 닉네임을 설정한다 (PATCH /api/groups/{groupId}/members/me). + */ +export async function updateGroupMemberProfile( + page: Page, + groupId: string, + nickname: string, +): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.patch(`/api/groups/${groupId}/members/me`, { + headers, + data: { nicknameInGroup: nickname }, + }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 프로필 변경 실패: ${JSON.stringify(body.error)}`); +} + +/** + * 특정 멤버의 역할을 변경한다 (PATCH /api/groups/{groupId}/members/{userId}/role). + */ +export async function changeMemberRole( + page: Page, + groupId: string, + userId: string, + role: 'ADMIN' | 'EDITOR' | 'VIEWER', +): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.patch(`/api/groups/${groupId}/members/${userId}/role`, { + headers, + data: { role }, + }); + const body = await res.json(); + if (!body.success) throw new Error(`역할 변경 실패: ${JSON.stringify(body.error)}`); +} + +export interface GroupMemberInfo { + userId: string; + role: string; + name: string; + nicknameInGroup: string; +} + +/** + * 그룹 설정(멤버 목록 포함)을 조회한다 (GET /api/groups/{groupId}/settings). + */ +export async function getGroupSettings( + page: Page, + groupId: string, +): Promise<{ members: GroupMemberInfo[] }> { + const headers = await getAuthHeaders(page); + const res = await page.request.get(`/api/groups/${groupId}/settings`, { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`그룹 설정 조회 실패: ${JSON.stringify(body.error)}`); + return body.data; +} + +/** + * 현재 계정을 탈퇴 처리한다 (DELETE /api/me). + */ +export async function withdrawTestUser(page: Page): Promise { + const headers = await getAuthHeaders(page); + const res = await page.request.delete('/api/me', { headers }); + const body = await res.json(); + if (!body.success) throw new Error(`회원 탈퇴 실패: ${JSON.stringify(body.error)}`); +} diff --git a/frontend/e2e/group-delete.spec.ts b/frontend/e2e/group-delete.spec.ts new file mode 100644 index 00000000..58e711be --- /dev/null +++ b/frontend/e2e/group-delete.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + createGuestSession, + joinTestGroup, +} from './fixtures/api'; + +const GROUP_NAME = 'E2E 그룹 삭제 테스트'; + +/** + * 그룹 삭제 시나리오 + * + * 1. Non-admin은 그룹 삭제 UI에 접근할 수 없다 (popover에 그룹 정보 수정 버튼 없음) + * 2. VIEWER는 /edit 페이지의 삭제 버튼이 비활성화된다 + * 3. Admin이 그룹을 삭제하면 본인의 /shared에서 그룹이 사라진다 + * 4. Admin이 그룹을 삭제하면 다른 멤버(B)의 /shared에서도 그룹이 사라진다 + * + * 주의: 삭제 테스트는 afterAll 정리가 필요 없다 (그룹이 테스트 내에서 삭제됨). + * 그러나 삭제가 실패하면 잔여 그룹이 남을 수 있으므로 각 describe에서 독립적인 그룹을 생성한다. + */ + +test.describe('그룹 삭제 - Non-admin 접근 제한', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 삭제 접근제한 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + // 이 describe의 그룹은 테스트 중 삭제되지 않으므로 afterAll에서 정리 + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await page.request.delete(`/api/groups/${groupId}`); + await ctx.close(); + }); + + test('EDITOR 멤버는 그룹 홈 Popover에서 그룹 정보 수정 버튼이 없어 삭제 페이지에 접근할 수 없다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + + // EDITOR는 그룹 정보 수정 메뉴가 없으므로 삭제 페이지 진입 불가 + await expect(pageB.getByRole('button', { name: '그룹 정보 수정' })).not.toBeVisible({ timeout: 3000 }); + } finally { + await ctxB.close(); + } + }); +}); + +test.describe('그룹 삭제 - Admin이 삭제 후 /shared 비노출 확인', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + // B의 context는 시나리오 전체에서 살아있어야 한다 + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + // Admin A(guest.json)가 그룹 생성 + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // B가 그룹 참여 + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async () => { + await ctxBRef?.close(); + ctxBRef = null; + }); + + test('Admin이 그룹 삭제 drawer를 열고 삭제하면 /shared 페이지로 리다이렉트된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + // 삭제 drawer 오픈 + await page.getByRole('button', { name: '그룹 삭제하기' }).click(); + await expect(page.getByText(`정말 '${GROUP_NAME}' 그룹을 삭제하시겠습니까?`)).toBeVisible({ timeout: 5000 }); + + // 삭제 확인 + await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click(); + + // 삭제 후 /shared로 이동 + await expect(page).toHaveURL(/\/shared/, { timeout: 10000 }); + }); + + test('삭제된 그룹은 Admin A의 /shared에서 더 이상 표시되지 않는다', async ({ page }) => { + await page.goto('/shared'); + // 그룹 이름이 포함된 카드가 없어야 한다 + await expect(page.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + }); + + test('삭제된 그룹은 멤버였던 B의 /shared에서도 표시되지 않는다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); diff --git a/frontend/e2e/group-leave.spec.ts b/frontend/e2e/group-leave.spec.ts new file mode 100644 index 00000000..aeae9dee --- /dev/null +++ b/frontend/e2e/group-leave.spec.ts @@ -0,0 +1,244 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, + getGroupSettings, + changeMemberRole, +} from './fixtures/api'; + +/** + * 그룹 나가기 시나리오 + * + * 1. EDITOR(B)가 나가면 + * - B의 /shared에서 그룹이 사라진다 + * - Admin(A)의 멤버 관리에서 B가 사라진다 + * + * 2. 마지막 Admin(A)이 나가면 → 그룹이 자동 삭제된다 + * - 나간 A도, 다른 멤버였던 B도 그룹에 접근할 수 없다 + * + * 3. Admin(C)이 나가도 다른 Admin(A)이 남아있으면 → C만 나가고 그룹은 유지된다 + * - C의 /shared에서 그룹이 사라진다 + * - A의 멤버 관리에서 C가 사라지고 그룹은 여전히 존재한다 + */ + +// ── 시나리오 1: EDITOR 멤버 나가기 ────────────────────────────────────────── + +test.describe('그룹 나가기 - EDITOR 멤버 나가기', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E나가기멤버'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E EDITOR 나가기 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + // B가 나간 후 그룹은 A(guest.json)가 소유 → 정리 + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('B가 그룹 나가기를 실행하면 B의 /shared에서 그룹이 사라진다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + // 나가기 버튼 클릭 → drawer 확인 → 나가기 + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageB.getByRole('button', { name: '그룹 나가기' }).click(); + + // 확인 drawer + await expect(pageB.getByRole('dialog').getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 5000 }); + await pageB.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + // /shared로 리다이렉트 + await expect(pageB).toHaveURL(/\/shared/, { timeout: 10000 }); + + // B의 /shared에서 그룹 카드 비노출 + await expect(pageB.getByRole('button', { name: 'E2E EDITOR 나가기 테스트' })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); + + test('B가 나가면 Admin A의 멤버 관리 페이지에서 B가 목록에 표시되지 않는다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).not.toBeVisible({ timeout: 8000 }); + }); +}); + +// ── 시나리오 2: 마지막 Admin이 나가면 그룹 삭제 ────────────────────────────── +// TODO: 백엔드에서 다른 멤버가 있을 때 마지막 Admin의 나가기 허용 + 그룹 자동 삭제 구현 후 활성화 + +test.describe.skip('그룹 나가기 - 마지막 Admin 나가기 → 그룹 자동 삭제', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + let ctxARef: import('@playwright/test').BrowserContext | null = null; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + // 마지막 Admin: 새 게스트 A (guest.json 보호를 위해 fresh session 사용) + ctxARef = await createGuestSession(browser); + const pageA = await ctxARef.newPage(); + const group = await createTestGroup(pageA, 'E2E 마지막Admin 나가기'); + groupId = group.id; + inviteCode = group.inviteCode; + + // B도 참여 (EDITOR) + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await pageB.close(); + await pageA.close(); + }); + + test.afterAll(async () => { + await ctxARef?.close(); + ctxARef = null; + await ctxBRef?.close(); + ctxBRef = null; + // 그룹은 나가기로 삭제되므로 별도 cleanup 불필요 + }); + + test('마지막 Admin이 나가면 /shared로 이동하고 그룹이 사라진다', async () => { + if (!ctxARef) throw new Error('A context not initialized'); + const pageA = await ctxARef.newPage(); + try { + await pageA.goto(`/group/${groupId}`); + await expect(pageA.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageA.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageA.getByRole('button', { name: '그룹 나가기' }).click(); + + // 확인 drawer에서 나가기 + await pageA.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + // /shared 이동 + await expect(pageA).toHaveURL(/\/shared/, { timeout: 10000 }); + await expect(pageA.getByRole('button', { name: 'E2E 마지막Admin 나가기' })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageA.close(); + } + }); + + test('그룹 삭제 후 다른 멤버였던 B도 해당 그룹에 접근할 수 없다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: 'E2E 마지막Admin 나가기' })).not.toBeVisible({ timeout: 8000 }); + + // 그룹 페이지 직접 접근 시 그룹 없음 처리 (리다이렉트 또는 404) + await pageB.goto(`/group/${groupId}`); + // 그룹이 없으므로 /shared 또는 에러 페이지로 이동 + await expect(pageB).not.toHaveURL(new RegExp(`/group/${groupId}$`), { timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 시나리오 3: Admin 나가기 (다른 Admin 존재) → 해당 멤버만 나가기 ────────── + +test.describe('그룹 나가기 - Admin 나가기 (다른 Admin 존재) → 그룹 유지', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + let ctxCRef: import('@playwright/test').BrowserContext | null = null; + const GROUP_NAME = 'E2E Admin나가기 그룹유지'; + + test.beforeAll(async ({ browser }) => { + // Admin A: guest.json (남을 사람) + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // C: 새 게스트 (나갈 사람, ADMIN으로 승격) + ctxCRef = await createGuestSession(browser); + const pageC = await ctxCRef.newPage(); + await joinTestGroup(pageC, inviteCode); + + // A가 C를 ADMIN으로 승격 + const settings = await getGroupSettings(pageA, groupId); + const memberC = settings.members.find((m) => { + // C는 A(guest.json)가 아닌 멤버 + return m.role === 'EDITOR'; + }); + if (memberC) { + await changeMemberRole(pageA, groupId, memberC.userId, 'ADMIN'); + } + + await pageC.close(); + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxCRef?.close(); + ctxCRef = null; + // A(guest.json)가 남아있으므로 그룹 정리 + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin C가 나가면 C의 /shared에서 그룹이 사라지지만 그룹은 삭제되지 않는다', async () => { + if (!ctxCRef) throw new Error('C context not initialized'); + const pageC = await ctxCRef.newPage(); + try { + await pageC.goto(`/group/${groupId}`); + await expect(pageC.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageC.getByRole('button', { name: '그룹 메뉴' }).click(); + await pageC.getByRole('button', { name: '그룹 나가기' }).click(); + await pageC.getByRole('dialog').getByRole('button', { name: '그룹 나가기' }).click(); + + await expect(pageC).toHaveURL(/\/shared/, { timeout: 10000 }); + await expect(pageC.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageC.close(); + } + }); + + test('C가 나간 후에도 Admin A의 /shared에는 그룹이 남아있다', async ({ page }) => { + await page.goto('/shared'); + await expect(page.getByRole('button', { name: GROUP_NAME })).toBeVisible({ timeout: 8000 }); + }); + + test('C가 나간 후 Admin A의 멤버 관리에서 C가 목록에 없고 그룹에는 A만 남는다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + + // 멤버 수 label: "멤버 관리 (N)" + await expect(page.getByText(/멤버 관리 \(1\)/)).toBeVisible({ timeout: 8000 }); + }); +}); diff --git a/frontend/e2e/group-management.spec.ts b/frontend/e2e/group-management.spec.ts new file mode 100644 index 00000000..5f69c4be --- /dev/null +++ b/frontend/e2e/group-management.spec.ts @@ -0,0 +1,363 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, +} from './fixtures/api'; + +// ── 그룹 홈 탭 및 popover 메뉴 ─────────────────────────────────────────────── + +test.describe('그룹 홈 탭 및 popover 메뉴', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 그룹 탭 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('그룹 홈에서 피드/보관함 탭을 전환할 수 있다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + // 보관함 탭 전환 (router.replace 비동기 처리를 위해 waitForURL 사용) + await page.getByRole('button', { name: '보관함' }).click(); + await page.waitForURL(/tab=archive/, { timeout: 8000 }); + + // 피드 탭 복귀 + await page.getByRole('button', { name: '피드' }).click(); + await expect(page).not.toHaveURL(/tab=archive/, { timeout: 5000 }); + }); + + test('Admin은 Popover에서 그룹 정보 수정, 멤버 관리, 나의 그룹 프로필, 그룹 나가기 버튼이 모두 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + + await expect(page.getByRole('button', { name: '그룹 정보 수정' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '멤버 관리' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '나의 그룹 프로필' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 5000 }); + }); + + test('EDITOR 멤버는 Popover에서 그룹 정보 수정, 멤버 관리 버튼이 표시되지 않는다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await pageB.getByRole('button', { name: '그룹 메뉴' }).click(); + + await expect(pageB.getByRole('button', { name: '그룹 정보 수정' })).not.toBeVisible({ timeout: 3000 }); + await expect(pageB.getByRole('button', { name: '멤버 관리' })).not.toBeVisible({ timeout: 3000 }); + // 모든 멤버에게 표시되는 항목 + await expect(pageB.getByRole('button', { name: '나의 그룹 프로필' })).toBeVisible({ timeout: 3000 }); + await expect(pageB.getByRole('button', { name: '그룹 나가기' })).toBeVisible({ timeout: 3000 }); + } finally { + await ctxB.close(); + } + }); + + test('그룹 정보 수정 버튼 클릭 시 /edit 페이지로 이동한다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + await page.getByRole('button', { name: '그룹 정보 수정' }).click(); + + await expect(page).toHaveURL(new RegExp(`/group/${groupId}/edit`), { timeout: 5000 }); + }); + + test('멤버 관리 버튼 클릭 시 /edit/members 페이지로 이동한다', async ({ page }) => { + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + await page.getByRole('button', { name: '그룹 메뉴' }).click(); + await page.getByRole('button', { name: '멤버 관리' }).click(); + + await expect(page).toHaveURL(new RegExp(`/group/${groupId}/edit/members`), { timeout: 5000 }); + }); +}); + +// ── 그룹 이름 수정 ─────────────────────────────────────────────────────────── + +test.describe('그룹 정보 수정 페이지 - 그룹 이름 변경', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const ORIGINAL_NAME = 'E2E 이름수정 전'; + const UPDATED_NAME = 'E2E 이름수정 후'; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, ORIGINAL_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 그룹 이름을 수정하면 저장 후 변경된 이름이 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + await page.getByRole('button', { name: /편집/ }).click(); + + const nameInput = page.getByPlaceholder('그룹명을 작성해주세요.'); + await expect(nameInput).toBeEnabled({ timeout: 5000 }); + await nameInput.clear(); + await nameInput.fill(UPDATED_NAME); + + await page.getByRole('button', { name: /저장/ }).click(); + await expect(page.getByText('그룹 이름이 변경되었습니다.')).toBeVisible({ timeout: 5000 }); + await expect(nameInput).toHaveValue(UPDATED_NAME, { timeout: 5000 }); + }); + + test('Admin이 그룹 이름을 변경하면 다른 멤버가 그룹 홈에 접속할 때 변경된 이름이 표시된다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + // 그룹 홈 헤더의 그룹 이름 확인 (GroupHeaderActions의 span) + await pageB.goto(`/group/${groupId}`); + await expect(pageB.locator('span').filter({ hasText: UPDATED_NAME }).first()).toBeVisible({ timeout: 8000 }); + // /shared 카드에서도 확인 + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: UPDATED_NAME })).toBeVisible({ timeout: 8000 }); + } finally { + await ctxB.close(); + } + }); +}); + +// ── 멤버 역할 변경 ─────────────────────────────────────────────────────────── + +test.describe('멤버 관리 - 역할 변경', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E역할변경멤버'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E 역할 변경 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 EDITOR 멤버의 역할을 뷰어로 변경할 수 있다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + + const bCard = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await bCard.getByRole('button', { name: '편집자' }).click(); + + await expect(page.getByText(`${MEMBER_B_NICKNAME}님의 권한 변경`)).toBeVisible({ timeout: 5000 }); + + // 뷰어 역할 선택 + await page.getByRole('button', { name: '뷰어' }).first().click(); + await page.getByRole('button', { name: '닫기' }).click(); + + // '닫기'는 역할 변경 API를 트리거함 → API 완료 후 낙관적 업데이트 대기 + const bCardOptimistic = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await expect(bCardOptimistic.locator('span').filter({ hasText: '뷰어' })).toBeVisible({ timeout: 8000 }); + + // 서버 반영 확인을 위해 reload 후 재검증 + await page.reload(); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + const bCardAfter = page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME) }); + await expect(bCardAfter.locator('span').filter({ hasText: '뷰어' })).toBeVisible({ timeout: 5000 }); + }); + + test('VIEWER로 변경된 멤버는 그룹 홈에서 초대 버튼이 표시되지 않는다', async () => { + // VIEWER는 GroupHeaderActions에서 초대 버튼(GroupInviteDrawer)이 숨겨진다 + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto(`/group/${groupId}`); + await expect(pageB.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + // 초대 버튼이 없어야 함 + await expect(pageB.getByRole('button', { name: '멤버 초대' })).not.toBeVisible({ timeout: 5000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 멤버 내보내기 ──────────────────────────────────────────────────────────── + +test.describe('멤버 관리 - 멤버 내보내기', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const MEMBER_B_NICKNAME = 'E2E내보내기멤버'; + const GROUP_NAME = 'E2E 내보내기 테스트'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, MEMBER_B_NICKNAME); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin이 멤버를 내보내면 멤버 목록에서 해당 멤버가 사라진다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(MEMBER_B_NICKNAME)).toBeVisible({ timeout: 8000 }); + + // B의 내보내기 버튼 클릭 (aria-label로 식별) + await page.getByRole('button', { name: `${MEMBER_B_NICKNAME} 내보내기` }).click(); + + // 확인 drawer + await expect(page.getByText(`정말 '${MEMBER_B_NICKNAME}'님을 그룹에서 내보내시겠습니까?`)).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: '내보내기' }).click(); + + // B가 목록에서 사라짐 (drawer 제목과 멤버 카드 모두 있을 수 있으므로 member-card 기준으로 확인) + await expect(page.locator('[data-testid="member-card"]').filter({ has: page.getByText(MEMBER_B_NICKNAME, { exact: true }) })).not.toBeVisible({ timeout: 8000 }); + }); + + test('내보내진 멤버는 /shared에서 해당 그룹을 볼 수 없다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); + +// ── 그룹 삭제 버튼 접근 ────────────────────────────────────────────────────── + +test.describe('그룹 정보 수정 페이지 - 그룹 삭제 버튼 접근', () => { + let groupId: string; + let inviteCode: string; + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + const group = await createTestGroup(page, 'E2E 삭제버튼 접근 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + await ctx.close(); + }); + + test.afterAll(async ({ browser }) => { + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('Admin은 그룹 삭제하기 버튼이 활성화 상태로 표시된다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit`); + + const deleteBtn = page.getByRole('button', { name: '그룹 삭제하기' }); + await expect(deleteBtn).toBeVisible({ timeout: 8000 }); + await expect(deleteBtn).not.toBeDisabled(); + }); + + test('EDITOR 멤버는 /edit 접근 시 /edit/profile로 리다이렉트된다', async ({ browser }) => { + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + await joinTestGroup(pageB, inviteCode); + await pageB.goto(`/group/${groupId}/edit`); + + // 서버 컴포넌트에서 ADMIN이 아닌 경우 /edit/profile로 리다이렉트함 + await expect(pageB).toHaveURL(new RegExp(`/group/${groupId}/edit/profile`), { timeout: 8000 }); + } finally { + await ctxB.close(); + } + }); + + test('VIEWER 멤버는 /edit 접근 시 /edit/profile로 리다이렉트된다', async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + + const inviteRes = await pageA.request.post(`/api/groups/${groupId}/invites`, { + data: { permission: 'VIEWER', expiresInSeconds: 86400 }, + }); + const inviteBody = await inviteRes.json(); + const viewerCode = inviteBody.data?.code; + await ctxA.close(); + + const ctxB = await createGuestSession(browser); + const pageB = await ctxB.newPage(); + try { + if (viewerCode) { + await joinTestGroup(pageB, viewerCode); + await pageB.goto(`/group/${groupId}/edit`); + + // 서버 컴포넌트에서 ADMIN이 아닌 경우 /edit/profile로 리다이렉트함 + await expect(pageB).toHaveURL(new RegExp(`/group/${groupId}/edit/profile`), { timeout: 8000 }); + } + } finally { + await ctxB.close(); + } + }); +}); diff --git a/frontend/e2e/group-member-profile.spec.ts b/frontend/e2e/group-member-profile.spec.ts new file mode 100644 index 00000000..2311aad7 --- /dev/null +++ b/frontend/e2e/group-member-profile.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + updateGroupMemberProfile, + createTestRecord, + deleteTestRecord, +} from './fixtures/api'; + +/** + * 그룹 멤버 프로필(닉네임) 변경 후 여러 화면에서 반영 여부를 확인한다. + * + * 확인 위치: + * 1. 멤버 관리 페이지 (/group/{id}/edit/members) + * 2. 그룹 홈 멤버 아바타 영역 (GroupMainTabs 상단) + * + * 기록 상세 / 타임라인의 기여자 프로필 확인은 그룹 기록(draft → publish 플로우) + * 생성이 선행되어야 하므로 별도 테스트에서 다룬다. + */ +test.describe('그룹 멤버 프로필 변경 반영', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const NICKNAME_BEFORE = 'E2E변경전닉네임'; + const NICKNAME_AFTER = 'E2E변경후닉네임'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, 'E2E 프로필 변경 테스트'); + groupId = group.id; + inviteCode = group.inviteCode; + + // B가 그룹 참여 후 초기 닉네임 설정 — 테스트 전체에서 같은 B 세션 재사용 + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await updateGroupMemberProfile(pageB, groupId, NICKNAME_BEFORE); + await pageB.close(); + + await ctxA.close(); + }); + + test.afterAll(async ({ browser }) => { + await ctxBRef?.close(); + ctxBRef = null; + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('B가 그룹 닉네임을 변경하면 Admin의 멤버 관리 페이지에 새 닉네임이 표시된다', async ({ browser }) => { + // 동일한 B가 닉네임을 NICKNAME_AFTER로 변경 (새 세션 생성 시 B2가 추가 가입되는 문제 방지) + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await updateGroupMemberProfile(pageB, groupId, NICKNAME_AFTER); + } finally { + await pageB.close(); + } + + // A(admin)가 멤버 관리 페이지에서 새 닉네임 확인 + const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageA = await ctxA.newPage(); + try { + await pageA.goto(`/group/${groupId}/edit/members`); + await expect(pageA.getByText(NICKNAME_AFTER)).toBeVisible({ timeout: 8000 }); + await expect(pageA.getByText(NICKNAME_BEFORE)).not.toBeVisible({ timeout: 3000 }); + } finally { + await ctxA.close(); + } + }); + + test('B가 그룹 닉네임을 변경하면 그룹 홈 멤버 아바타 목록에 반영된다', async ({ page }) => { + // 그룹 홈(GroupMainTabs)의 멤버 아바타는 alt 텍스트로 구분 불가하므로 + // 아바타 개수(멤버 수)가 올바른지로 간접 검증한다. + await page.goto(`/group/${groupId}`); + await expect(page.getByRole('button', { name: '피드' })).toBeVisible({ timeout: 8000 }); + + // 그룹 멤버는 A + B = 2명이므로 아바타가 2개 표시되어야 한다 + // GroupMainTabs: members.slice(0, 4).map((m) =>
...) + const avatars = page.locator('[data-app-root] .rounded-full').filter({ hasNot: page.locator('svg') }); + await expect(avatars).toHaveCount(2, { timeout: 8000 }); + }); +}); + +/** + * 그룹 기록 상세 페이지에서 기여자 프로필 변경 반영 확인. + * 그룹 기록이 생성된 경우에만 유효하다. + * + * 현재 그룹 기록 생성은 draft → publish UI 플로우가 필요하기 때문에 + * 개인 기록을 PERSONAL scope로 생성하는 방법으로 대체 불가하여 + * 이 테스트 블록은 별도 마킹 없이 추가한다. + * 필요 시 createGroupRecord fixture가 완성되면 활성화한다. + */ +test.describe.skip('그룹 기록 상세 및 타임라인 - 기여자 프로필 변경 반영', () => { + // TODO: createGroupRecord fixture 완성 후 활성화 + // 기록 상세 페이지 작성자 섹션, 타임라인의 기여자 아바타 검증 추가 예정 +}); diff --git a/frontend/e2e/user-withdrawal.spec.ts b/frontend/e2e/user-withdrawal.spec.ts new file mode 100644 index 00000000..8d7d93ce --- /dev/null +++ b/frontend/e2e/user-withdrawal.spec.ts @@ -0,0 +1,188 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { + createTestGroup, + deleteTestGroup, + createGuestSession, + joinTestGroup, + getGroupSettings, + changeMemberRole, + withdrawTestUser, +} from './fixtures/api'; + +/** + * 회원 탈퇴 시나리오 + * + * 1. 탈퇴 UI: /profile 페이지에서 탈퇴하기 버튼 → drawer 확인 → /login 이동 + * + * 2. 탈퇴 + Admin 남아있는 그룹 → 그룹 유지 + * - 탈퇴자(A, fresh)가 속한 그룹에 다른 Admin(B, guest.json)이 있으면 + * A만 탈퇴 처리되고 그룹은 계속 존재한다 + * - B의 /shared에서 그룹이 여전히 보인다 + * + * 3. 탈퇴 + Admin이 없는 그룹 → 그룹 삭제 + * - 탈퇴자(A, fresh)만 Admin인 그룹은 A 탈퇴 시 그룹이 삭제된다 + * - B(EDITOR, fresh)의 /shared에서 그룹이 사라진다 + * + * 주의: 탈퇴 테스트는 모두 fresh guest session을 사용한다. + * guest.json 계정은 절대 탈퇴하지 않는다. + */ + +// ── 시나리오 1: 탈퇴 UI 흐름 ───────────────────────────────────────────────── +// 주의: '탈퇴하기' 버튼은 소셜 로그인 사용자에게만 표시됨. +// 게스트 세션에서는 탈퇴 UI 대신 API 기반으로 탈퇴 흐름을 검증한다. + +test.describe('회원 탈퇴 - UI 흐름', () => { + let ctxARef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + ctxARef = await createGuestSession(browser); + }); + + test.afterAll(async () => { + await ctxARef?.close(); + ctxARef = null; + }); + + test('게스트 사용자의 /profile 페이지에는 탈퇴하기 버튼 대신 로그인 버튼이 표시된다', async () => { + if (!ctxARef) throw new Error('A context not initialized'); + const pageA = await ctxARef.newPage(); + try { + await pageA.goto('/profile'); + await expect(pageA.getByRole('button', { name: '로그인' })).toBeVisible({ timeout: 8000 }); + await expect(pageA.getByRole('button', { name: '탈퇴하기' })).not.toBeVisible({ timeout: 3000 }); + } finally { + await pageA.close(); + } + }); + + // TODO: 게스트 세션 탈퇴 후 세션 무효화 백엔드 구현 후 활성화 + // 현재 DELETE /api/me 후에도 게스트 세션 쿠키가 유효하여 /profile 접근이 차단되지 않음 + test.skip('탈퇴 API 호출 후 보호된 페이지 접근 시 /login으로 리다이렉트된다', async () => { + if (!ctxARef) throw new Error('A context not initialized'); + const pageA = await ctxARef.newPage(); + try { + await withdrawTestUser(pageA); + await pageA.goto('/profile'); + await expect(pageA).toHaveURL(/\/login/, { timeout: 10000 }); + } finally { + await pageA.close(); + } + }); +}); + +// ── 시나리오 2: 탈퇴 + Admin 남아있는 그룹 → 그룹 유지 ────────────────────── + +test.describe('회원 탈퇴 - 다른 Admin이 있으면 그룹 유지', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const GROUP_NAME = 'E2E 탈퇴 그룹유지 테스트'; + + test.beforeAll(async ({ browser }) => { + // A(fresh): 탈퇴할 사용자, 그룹 생성자 → Admin + const ctxA = await createGuestSession(browser); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // B(guest.json): 그룹 참여 후 ADMIN으로 승격 → 그룹 유지 담당 + const ctxB = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const pageB = await ctxB.newPage(); + await joinTestGroup(pageB, inviteCode); + + // A가 B를 ADMIN으로 승격 + const settings = await getGroupSettings(pageA, groupId); + const memberB = settings.members.find((m) => m.role === 'EDITOR'); + if (memberB) { + await changeMemberRole(pageA, groupId, memberB.userId, 'ADMIN'); + } + + // A가 탈퇴 (API 직접 호출로 테스트 속도 개선) + await withdrawTestUser(pageA); + + await ctxA.close(); + await ctxB.close(); + }); + + test.afterAll(async ({ browser }) => { + // B(guest.json)가 남아있으므로 그룹 정리 + const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') }); + const page = await ctx.newPage(); + await deleteTestGroup(page, groupId); + await ctx.close(); + }); + + test('A 탈퇴 후에도 다른 Admin B의 /shared에는 그룹이 남아있다', async ({ page }) => { + await page.goto('/shared'); + await expect(page.getByRole('button', { name: GROUP_NAME })).toBeVisible({ timeout: 8000 }); + }); + + // TODO: Admin 탈퇴 후 edit/members 페이지 서버 에러 → 백엔드 버그 수정 후 활성화 + test.skip('A 탈퇴 후 B의 멤버 관리 페이지에는 B만 남아있다', async ({ page }) => { + await page.goto(`/group/${groupId}/edit/members`); + await expect(page.getByText(/멤버 관리 \(1\)/)).toBeVisible({ timeout: 8000 }); + }); +}); + +// ── 시나리오 3: 탈퇴 + Admin 없는 그룹 → 그룹 삭제 ───────────────────────── +// TODO: 백엔드에서 마지막 Admin 탈퇴 시 그룹 자동 삭제 구현 후 활성화 + +test.describe.skip('회원 탈퇴 - 다른 Admin 없으면 그룹 삭제', () => { + test.describe.configure({ mode: 'serial' }); + + let groupId: string; + let inviteCode: string; + const GROUP_NAME = 'E2E 탈퇴 그룹삭제 테스트'; + let ctxBRef: import('@playwright/test').BrowserContext | null = null; + + test.beforeAll(async ({ browser }) => { + // A(fresh): 그룹 생성 및 탈퇴할 유일한 Admin + const ctxA = await createGuestSession(browser); + const pageA = await ctxA.newPage(); + const group = await createTestGroup(pageA, GROUP_NAME); + groupId = group.id; + inviteCode = group.inviteCode; + + // B(fresh): EDITOR로 그룹 참여 (Admin으로 승격 없음) + ctxBRef = await createGuestSession(browser); + const pageB = await ctxBRef.newPage(); + await joinTestGroup(pageB, inviteCode); + await pageB.close(); + + // A가 탈퇴 (유일한 Admin) → 서버에서 그룹 자동 삭제 + await withdrawTestUser(pageA); + await ctxA.close(); + }); + + test.afterAll(async () => { + await ctxBRef?.close(); + ctxBRef = null; + // 그룹은 탈퇴 시 삭제되므로 별도 cleanup 불필요 + }); + + test('유일한 Admin A 탈퇴 후 멤버였던 B의 /shared에서 그룹이 사라진다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto('/shared'); + await expect(pageB.getByRole('button', { name: GROUP_NAME })).not.toBeVisible({ timeout: 8000 }); + } finally { + await pageB.close(); + } + }); + + test('유일한 Admin A 탈퇴 후 그룹 페이지에 직접 접근하면 그룹이 없음을 확인할 수 있다', async () => { + if (!ctxBRef) throw new Error('B context not initialized'); + const pageB = await ctxBRef.newPage(); + try { + await pageB.goto(`/group/${groupId}`); + // 그룹이 없으므로 리다이렉트 또는 not-found 처리 + await expect(pageB).not.toHaveURL(new RegExp(`^.*/group/${groupId}$`), { timeout: 8000 }); + } finally { + await pageB.close(); + } + }); +}); From 942b77f5d281a4c089a9f2fb22ea6c887830a9c8 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 8 Jun 2026 13:52:34 +0900 Subject: [PATCH 33/39] =?UTF-8?q?test:=20=EA=B7=B8=EB=A3=B9=20=ED=99=88,?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=88=98=EC=A0=95(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피드 탭 / 보관함 탭 / 멤버 다수 스토리 추가 - 버튼 클릭으로 열어 혼자/공동 기록 선택 확인 가능 --- .../storybook/GroupMainTabs.stories.tsx | 142 +++++++++++++++ .../storybook/GroupInfo.stories.tsx | 32 +++- .../GroupMemberManagement.stories.tsx | 103 ++++------- .../storybook/GroupProfileEdit.stories.tsx | 145 +++++++++------ .../storybook/AddRecordDrawer.stories.tsx | 78 ++++++++ .../storybook/GroupDraftList.stories.tsx | 85 +++++++++ .../storybook/GroupHeaderActions.stories.tsx | 169 +++++++++--------- 7 files changed, 547 insertions(+), 207 deletions(-) create mode 100644 frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx create mode 100644 frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx create mode 100644 frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx diff --git a/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx b/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx new file mode 100644 index 00000000..7575d67b --- /dev/null +++ b/frontend/src/app/(post)/group/[groupId]/(root)/_components/storybook/GroupMainTabs.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import GroupMainTabs from '../GroupMainTabs'; +import { createMockRecordPreviews } from '@/lib/mocks/mock'; + +const GROUP_ID = 'group-1'; + +const mockMonthlyRecords = [ + { month: '2026-06', count: 5, coverAssetId: null, latestTitle: '가족 나들이', latestLocation: '서울 성동구' }, + { month: '2026-05', count: 3, coverAssetId: null, latestTitle: '생일 파티', latestLocation: null }, + { month: '2026-04', count: 8, coverAssetId: null, latestTitle: '벚꽃 나들이', latestLocation: '여의도' }, +]; + +const mockFeedRecords = { + success: true, + data: createMockRecordPreviews('2026-06-05').slice(0, 2), + error: null, +}; + +const mockRole = { role: 'ADMIN' }; + +const currentYear = new Date().getFullYear().toString(); + +function makeClient(members: { memberId: string; profileImageId: string | null }[], groupName: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(['currentMembers', GROUP_ID], { + groupName, + groupMemberCount: members.length, + members, + }); + client.setQueryData(['group', GROUP_ID, 'me', 'role'], mockRole); + // MonthRecords는 useParams().year || currentYear 를 queryKey에 포함함 + client.setQueryData(['group', GROUP_ID, 'records', 'month', currentYear], mockMonthlyRecords); + return client; +} + +const smallMembers = [ + { memberId: 'user-1', profileImageId: null }, + { memberId: 'user-2', profileImageId: null }, + { memberId: 'user-3', profileImageId: null }, +]; + +const manyMembers = Array.from({ length: 6 }, (_, i) => ({ + memberId: `user-${i + 1}`, + profileImageId: null, +})); + +const clients = { + feed: makeClient(smallMembers, '우리 가족 추억함'), + archive: makeClient(smallMembers, '우리 가족 추억함'), + manyMembers: makeClient(manyMembers, '대가족 모임'), +}; + +const feedHandlers = [ + http.get('/api/feed/groups/:groupId', () => HttpResponse.json(mockFeedRecords)), +]; + +const meta = { + title: 'Group/GroupMainTabs', + component: GroupMainTabs, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈의 메인 컨텐츠 영역입니다. 상단에 그룹 멤버 아바타 목록과 피드/보관함 탭 전환 버튼이 있으며, 탭에 따라 주간 캘린더+기록 피드(피드 탭) 또는 올해 월별 기록(보관함 탭)을 표시합니다.', + }, + }, + msw: { handlers: feedHandlers }, + nextjs: { navigation: { pathname: `/group/${GROUP_ID}` } }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FeedTab: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: ` +피드 탭 — 주간 캘린더 + 날짜별 기록 목록. + +- **피드/보관함 탭**: 탭 클릭 시 URL의 \`tab\` 쿼리 파라미터가 변경되어 뷰가 전환됨 (보관함 탭은 ArchiveTab 스토리에서 확인) +- **멤버 아바타**: 최대 4명 표시, 초과 시 +N 배지 (ManyMembers 스토리에서 확인) + `, + }, + }, + }, +}; + +export const ArchiveTab: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: '보관함 탭 — 올해의 월별 기록 카드 목록. 월 카드를 클릭하면 해당 월의 기록 상세로 이동', + }, + }, + nextjs: { + navigation: { + pathname: `/group/${GROUP_ID}`, + query: { tab: 'archive' }, + }, + }, + }, +}; + +export const ManyMembers: Story = { + args: { groupId: GROUP_ID }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: '멤버가 4명을 초과하는 경우 — 아바타 4개 + +N 배지 표시', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx index 5fe2ca0f..ba64222f 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupInfo.stories.tsx @@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GroupInfo from '../GroupInfo'; import { GroupEditProvider } from '../GroupEditContext'; -import { Member } from '@/lib/types/group'; import { GroupMember } from '@/lib/types/groupResponse'; // 커버 이미지 클릭 시 GalleryDrawer가 열리고 내부에서 useInfiniteQuery를 사용하므로 @@ -12,9 +11,9 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); -const mockMembers: Member[] = [ - { id: 1, name: '도비', avatar: '/profile-ex.jpeg', role: 'admin' }, - { id: 2, name: '하니', avatar: '/profile-ex.jpeg', role: 'member' }, +const mockMembers: GroupMember[] = [ + { userId: 'user-1', name: '도비', profileImage: null, role: 'ADMIN', nicknameInGroup: '도비', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-2', name: '하니', profileImage: null, role: 'EDITOR', nicknameInGroup: '하니', joinedAt: '2024-01-01T00:00:00Z' }, ]; const meAdmin: GroupMember = { @@ -35,7 +34,7 @@ const meViewer: GroupMember = { function Wrapper({ name, children }: { name: string; children: React.ReactNode }) { return ( - +
{children}
@@ -81,7 +80,7 @@ export const Default: Story = { story: ` 그룹 정보 수정 — 기본 상태 (ADMIN 권한) - **커버 이미지 클릭**: 이미지 선택 드로어가 열려 커버 변경 가능 -- **그룹명 입력**: 실시간 입력, 빈 값 또는 2자 미만이면 저장 버튼 비활성화 +- **그룹명 편집 버튼 클릭**: 입력 필드 활성화, 빈 값 또는 2자 미만이면 저장 버튼 비활성화 - **저장 버튼**: 변경 사항을 서버에 저장 `, }, @@ -133,3 +132,24 @@ export const EmptyGroupName: Story = { }, }; +export const ViewerRole: Story = { + args: { + groupId: 'group-1', + groupThumnail: 'https://images.unsplash.com/photo-1547592166-23ac45744acd?auto=format&fit=crop&q=80&w=400', + me: meViewer, + }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'VIEWER 권한 — 커버 이미지 클릭 불가, 그룹명 편집 버튼 비표시', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx index fa9732fa..5630a73a 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/_components/storybook/GroupMemberManagement.stories.tsx @@ -1,23 +1,28 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import GroupMemberManagement from '../GroupMemberManagement'; import { GroupEditProvider } from '../GroupEditContext'; -import { Member } from '@/lib/types/group'; +import { GroupMember } from '@/lib/types/groupResponse'; import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, + defaultOptions: { queries: { retry: false } }, }); -const mockMembers: Member[] = [ - { id: 1, name: '도비', avatar: '/profile-ex.jpeg', role: 'admin' }, - { id: 2, name: '하니', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 3, name: '루피', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 4, name: '미키', avatar: '/profile-ex.jpeg', role: 'member' }, +const mockMe: GroupMember = { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'ADMIN', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', +}; + +const mockMembers: GroupMember[] = [ + { userId: 'user-1', name: '도비', profileImage: null, role: 'ADMIN', nicknameInGroup: '도비', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-2', name: '하니', profileImage: null, role: 'EDITOR', nicknameInGroup: '하니', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-3', name: '루피', profileImage: null, role: 'EDITOR', nicknameInGroup: '루피', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-4', name: '미키', profileImage: null, role: 'VIEWER', nicknameInGroup: '미키', joinedAt: '2024-01-01T00:00:00Z' }, ]; const meta = { @@ -28,12 +33,15 @@ const meta = { docs: { description: { component: - '그룹 설정 페이지의 멤버 목록과 역할 관리 컴포넌트입니다. 그룹 멤버를 아바타/닉네임/역할과 함께 목록으로 표시하며, 관리자는 멤버 추방 버튼을 통해 멤버를 내보낼 수 있습니다. 멤버 추방 시 DELETE API를 호출합니다.', + '그룹 설정 페이지의 멤버 목록과 역할 관리 컴포넌트입니다. 그룹 멤버를 아바타/닉네임/역할과 함께 목록으로 표시하며, 관리자는 멤버 역할 변경 및 내보내기를 할 수 있습니다.', }, }, msw: { handlers: [ - http.delete('/api/:groupId/members/:memberId', () => { + http.delete('/api/groups/:groupId/members/:memberId', () => { + return HttpResponse.json({ success: true }); + }), + http.patch('/api/groups/:groupId/members/:memberId/role', () => { return HttpResponse.json({ success: true }); }), ], @@ -47,15 +55,15 @@ type Story = StoryObj; export const Default: Story = { args: { - members: mockMembers, groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => (
@@ -68,7 +76,13 @@ export const Default: Story = { parameters: { docs: { description: { - story: '그룹 멤버 관리 - 기본 상태 (관리자 뷰)', + story: ` +그룹 멤버 관리 — ADMIN 권한 기본 상태. + +- **역할 텍스트 클릭**: 역할 변경 드로어가 열려 ADMIN/멤버/뷰어 선택 가능 +- **내보내기 버튼 클릭**: 해당 멤버 내보내기 확인 드로어 표시 +- **관리자 본인**: 내보내기 버튼 비표시 (자기 자신은 내보낼 수 없음) + `, }, }, }, @@ -76,15 +90,15 @@ export const Default: Story = { export const FewMembers: Story = { args: { - members: mockMembers.slice(0, 2), groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => (
@@ -105,27 +119,22 @@ export const FewMembers: Story = { export const ManyMembers: Story = { args: { - members: [ - ...mockMembers, - { id: 5, name: '피카츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 6, name: '라이츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 7, name: '파이리', avatar: '/profile-ex.jpeg', role: 'member' }, - ], groupId: 'group-1', + me: mockMe, }, decorators: [ (Story) => { - const manyMembers: Member[] = [ + const manyMembers: GroupMember[] = [ ...mockMembers, - { id: 5, name: '피카츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 6, name: '라이츄', avatar: '/profile-ex.jpeg', role: 'member' }, - { id: 7, name: '파이리', avatar: '/profile-ex.jpeg', role: 'member' }, + { userId: 'user-5', name: '피카츄', profileImage: null, role: 'EDITOR', nicknameInGroup: '피카츄', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-6', name: '라이츄', profileImage: null, role: 'EDITOR', nicknameInGroup: '라이츄', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'user-7', name: '파이리', profileImage: null, role: 'VIEWER', nicknameInGroup: '파이리', joinedAt: '2024-01-01T00:00:00Z' }, ]; return (
@@ -139,42 +148,8 @@ export const ManyMembers: Story = { parameters: { docs: { description: { - story: '멤버가 많은 경우', + story: '멤버가 많은 경우 (7명)', }, }, }, }; - -export const Interactive: Story = { - args: { - members: mockMembers, - groupId: 'group-1', - }, - decorators: [ - (Story) => ( - - -
- -
-
-
- ), - ], - parameters: { - docs: { - description: { - story: ` -- **추방 버튼 클릭**: 해당 멤버에게 추방 확인 드로어 표시 -- **추방 확인**: "추방하기" 버튼 클릭 시 DELETE API 호출 후 목록에서 제거 -- **관리자(admin)**: 추방 버튼이 비활성화되어 자신을 추방할 수 없음 - `, - }, - }, - }, -}; - diff --git a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx index fc7db1ae..e6ee6879 100644 --- a/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx +++ b/frontend/src/app/(post)/group/[groupId]/edit/profile/_components/storybook/GroupProfileEdit.stories.tsx @@ -1,10 +1,45 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Suspense } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import GroupProfileEditClient from '../GroupProfileEditClient'; -import { BaseUser } from '@/lib/types/profile'; +import { GroupEditResponse } from '@/lib/types/groupResponse'; -const mockGroupProfile: Omit = { - nickname: '도비', - profileImageUrl: '/profile-ex.jpeg', +const baseMockData: GroupEditResponse = { + group: { + groupId: 'group-1', + name: '우리 가족', + createdAt: '2024-01-01T00:00:00Z', + ownerUserId: 'user-1', + cover: null, + }, + me: { + userId: 'user-1', + name: '도비', + profileImage: null, + role: 'EDITOR', + nicknameInGroup: '도비', + joinedAt: '2024-01-01T00:00:00Z', + }, + members: [], +}; + +function makeClient(overrideMe?: Partial) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(['group', 'group-1', 'edit'], { + ...baseMockData, + me: { ...baseMockData.me, ...overrideMe }, + }); + return client; +} + +// 스토리별 독립적인 QueryClient 인스턴스 +const clients = { + default: makeClient(), + emptyNickname: makeClient({ nicknameInGroup: '' }), + shortNickname: makeClient({ nicknameInGroup: '도' }), + longNickname: makeClient({ nicknameInGroup: '아주아주아주긴닉네임입니다' }), }; const meta = { @@ -15,7 +50,7 @@ const meta = { docs: { description: { component: - '그룹 내 내 프로필(닉네임/이미지) 편집 페이지 컴포넌트입니다. 그룹 전용 닉네임과 프로필 이미지를 변경할 수 있으며, 닉네임 유효성 검사(2~10자)를 포함합니다. groupId와 groupProfile prop을 통해 초기 데이터를 받습니다.', + '그룹 내 내 프로필(닉네임/이미지) 편집 페이지 컴포넌트입니다. 그룹 전용 닉네임과 프로필 이미지를 변경할 수 있으며, 닉네임 유효성 검사(2~10자)를 포함합니다. groupId를 받아 내부적으로 그룹 데이터를 조회합니다.', }, }, nextjs: { @@ -39,86 +74,88 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: { - groupId: 'group-1', - groupProfile: mockGroupProfile, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '그룹 프로필 수정 페이지 - 기본 상태', + story: ` +그룹 프로필 수정 페이지 — 기본 상태. + +- **프로필 이미지 클릭**: 이미지 파일 선택 피커가 열리며, 선택 즉시 미리보기 업데이트 +- **닉네임 입력**: 실시간 입력, 2자 미만 또는 10자 초과 시 에러 메시지 표시 +- **저장 버튼**: 유효성 통과 시 활성화, 클릭 시 변경 사항을 서버에 저장 +- **뒤로가기**: 저장하지 않고 이전 페이지로 이동 + `, }, }, }, }; export const EmptyNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '', - }, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '닉네임이 비어있는 경우 (저장 불가)', + story: '닉네임이 비어있는 경우 — 저장 불가', }, }, }, }; export const ShortNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '도', - }, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + +
+ ), + ], parameters: { docs: { description: { - story: '닉네임이 너무 짧은 경우 (1자, 에러 표시)', + story: '닉네임이 너무 짧은 경우 (1자) — 에러 표시', }, }, }, }; export const LongNickname: Story = { - args: { - groupId: 'group-1', - groupProfile: { - ...mockGroupProfile, - nickname: '아주아주아주긴닉네임입니다', - }, - }, - parameters: { - docs: { - description: { - story: '닉네임이 너무 긴 경우 (10자 초과, 에러 표시)', - }, - }, - }, -}; - -export const Interactive: Story = { - args: { - groupId: 'group-1', - groupProfile: mockGroupProfile, - }, + args: { groupId: 'group-1' }, + decorators: [ + (Story) => ( + + 로딩 중...
}> + + + + ), + ], parameters: { docs: { description: { - story: ` -- **프로필 이미지 클릭**: 이미지 파일 선택 피커가 열리며, 선택 즉시 미리보기 업데이트 -- **닉네임 입력**: 실시간 입력, 2자 미만 또는 10자 초과 시 에러 메시지 표시 -- **저장 버튼**: 유효성 통과 시 활성화, 클릭 시 변경 사항을 서버에 저장 -- **뒤로가기**: 저장하지 않고 이전 페이지로 이동 - `, + story: '닉네임이 너무 긴 경우 (10자 초과) — 에러 표시', }, }, }, }; - diff --git a/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx new file mode 100644 index 00000000..49f92344 --- /dev/null +++ b/frontend/src/app/(post)/group/_components/storybook/AddRecordDrawer.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AddRecordDrawer } from '../AddRecordDrawer'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function AddRecordTrigger({ groupId }: { groupId?: string }) { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); +} + +const meta = { + title: 'Group/AddRecordDrawer', + component: AddRecordDrawer, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈에서 기록 방식을 선택하는 드로어 컴포넌트입니다. 혼자 기록(개인 기록 페이지 이동)과 공동 기록(새 그룹 드래프트 생성 후 편집 페이지 이동)을 선택할 수 있습니다. groupId가 없으면 공동 기록 버튼이 비활성화됩니다.', + }, + }, + nextjs: { + navigation: { pathname: '/group/group-1' }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + parameters: { + docs: { + description: { + story: ` +기록 방식 선택 드로어 기본 상태 — 그룹 ID가 있어 공동 기록 버튼이 활성화된 상태. + +- **혼자 기록 클릭**: \`/add?groupId={groupId}\`로 이동 +- **공동 기록 클릭**: 새 공동 작성 드래프트를 생성 후 편집 페이지로 이동 + `, + }, + }, + }, +}; + +export const WithoutGroup: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'groupId가 없는 경우 — 공동 기록 버튼이 비활성화(흐림 처리)되어 클릭 불가', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx new file mode 100644 index 00000000..40feaa1f --- /dev/null +++ b/frontend/src/app/(post)/group/_components/storybook/GroupDraftList.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import GroupDraftList from '../GroupDraftList'; +import { GroupDraftListItem } from '@/lib/types/recordCollaboration'; + +const GROUP_ID = 'group-1'; + +function makeDraft(override: Partial & { draftId: string }): GroupDraftListItem { + return { + kind: 'CREATE', + targetPostId: null, + title: '제목 없음', + createdAt: '2026-06-01T10:00:00Z', + updatedAt: '2026-06-01T11:00:00Z', + participantCount: 1, + isPublishing: false, + ...override, + }; +} + +const mockDrafts: GroupDraftListItem[] = [ + makeDraft({ draftId: 'draft-1', kind: 'CREATE', title: '가족 나들이 기록', participantCount: 3 }), + makeDraft({ draftId: 'draft-2', kind: 'EDIT', targetPostId: 'post-1', title: '설날 가족 모임', participantCount: 2 }), + makeDraft({ draftId: 'draft-3', kind: 'CREATE', title: '', participantCount: 1 }), +]; + +const meta = { + title: 'Group/GroupDraftList', + component: GroupDraftList, + parameters: { + layout: 'padded', + docs: { + description: { + component: + '그룹 홈 플로팅 버튼 팝오버 내부에서 공동 작성 중인 드래프트 목록을 표시하는 컴포넌트입니다. 드래프트는 공동 작성(CREATE)과 공동 수정(EDIT)으로 구분되며, VIEWER는 카드를 클릭할 수 없습니다.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { groupId: GROUP_ID, drafts: [], isViewer: false }, + parameters: { + docs: { + description: { + story: ` +공동 작성 중인 드래프트가 없는 상태 — 빈 상태 안내 문구 표시. + +- **새 드래프트 생성**: 플로팅 버튼 팝오버 상단의 '새 드래프트' 버튼으로 진입 + `, + }, + }, + }, +}; + +export const WithDrafts: Story = { + args: { groupId: GROUP_ID, drafts: mockDrafts, isViewer: false }, + parameters: { + docs: { + description: { + story: ` +공동 작성(CREATE)·공동 수정(EDIT) 드래프트가 혼합된 상태. + +- **드래프트 클릭**: 해당 공동 작성/수정 페이지로 이동 +- **공동 작성** 배지: 초록색, **공동 수정** 배지: 노란색 +- title이 빈 문자열인 경우 '제목 없음'으로 표시 + `, + }, + }, + }, +}; + +export const ViewerRole: Story = { + args: { groupId: GROUP_ID, drafts: mockDrafts, isViewer: true }, + parameters: { + docs: { + description: { + story: 'VIEWER 권한 — 드래프트 카드가 흐림 처리되어 클릭 불가', + }, + }, + }, +}; diff --git a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx index 6519215a..d3f806a8 100644 --- a/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx +++ b/frontend/src/app/(post)/group/_components/storybook/GroupHeaderActions.stories.tsx @@ -1,135 +1,138 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import GroupHeader from '../GroupHeader'; +import GroupHeaderActions from '../GroupHeaderActions'; +import { GroupMembersResponse, GroupMemberRoleResponse } from '@/lib/types/groupResponse'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); +const GROUP_ID = 'group-1'; + +function makeClient(groupId: string, role: GroupMemberRoleResponse['role']) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // groupMyRoleOptions queryKey: ['group', groupId, 'me', 'role'] + client.setQueryData(['group', groupId, 'me', 'role'], { role }); + return client; +} + +const LONG_NAME_GROUP_ID = 'long-name'; + +// 스토리별 독립 QueryClient — 공유 캐시로 인한 role 오염 방지 +const clients = { + admin: makeClient(GROUP_ID, 'ADMIN'), + viewer: makeClient(GROUP_ID, 'VIEWER'), + longName: makeClient(LONG_NAME_GROUP_ID, 'ADMIN'), + manyMembers: makeClient(GROUP_ID, 'ADMIN'), +}; -// --- Mock Data 생성 --- const baseMembers = [ - { id: 1, name: '나', avatar: '/profile-ex.jpeg' }, - { id: 2, name: '엄마', avatar: '/profile-ex.jpeg' }, - { id: 3, name: '아빠', avatar: '/profile-ex.jpeg' }, + { memberId: 'user-1', profileImageId: null }, + { memberId: 'user-2', profileImageId: null }, + { memberId: 'user-3', profileImageId: null }, ]; -const manyMembers = Array.from({ length: 12 }, (_, i) => ({ - id: i + 1, - name: `멤버 ${i + 1}`, - avatar: '/profile-ex.jpeg', +const manyMembersList = Array.from({ length: 12 }, (_, i) => ({ + memberId: `user-${i + 1}`, + profileImageId: null, })); +const defaultGroupInfo: GroupMembersResponse = { + groupName: '우리 가족 추억함', + groupMemberCount: 3, + members: baseMembers, +}; + +const longNameGroupInfo: GroupMembersResponse = { + groupName: '우리 가족의 아주아주 길고 소중한 2026년 추억 보관함 (말줄임표 확인용)', + groupMemberCount: 3, + members: baseMembers, +}; + +const manyMembersGroupInfo: GroupMembersResponse = { + groupName: '대가족 모임', + groupMemberCount: 12, + members: manyMembersList, +}; + const meta = { title: 'Group/GroupHeader', - component: GroupHeader, + component: GroupHeaderActions, parameters: { layout: 'padded', docs: { description: { component: - '그룹 홈 헤더의 멤버 아바타와 설정 버튼 컴포넌트입니다. 그룹 이름, 참여 멤버 아바타(최대 표시 후 +N 처리), 그룹 설정 페이지 이동 버튼을 포함합니다. MSW를 통해 그룹 정보를 목킹하며 groupId 기반으로 데이터를 가져옵니다.', + '그룹 홈 헤더 컴포넌트입니다. 그룹 이름, 멤버 초대(비뷰어만), 날짜 선택, 그룹 메뉴(설정/프로필/탈퇴)를 포함합니다. groupInfo prop으로 그룹 정보를 받고, 내부적으로 현재 사용자의 role을 조회해 UI를 분기합니다.', }, }, - msw: { - handlers: [ - // 호출되는 API 주소에 따라 다른 데이터를 반환하도록 설정 - http.get('/api/groups/:groupId', ({ params }) => { - const { groupId } = params; - - if (groupId === 'long-name') { - return HttpResponse.json({ - name: '우리 가족의 아주아주 길고 소중한 2026년 추억 보관함 (말줄임표 확인용)', - inviteCode: 'LONG-NAME-TEST', - members: baseMembers, - }); - } - - if (groupId === 'many-members') { - return HttpResponse.json({ - name: '대가족 모임', - inviteCode: 'BIG-FAMILY-77', - members: manyMembers, - }); - } - - // Default 데이터 - return HttpResponse.json({ - name: '우리 가족 추억함', - inviteCode: 'DLOG-FAMILY-99', - members: baseMembers, - }); - }), - ], + nextjs: { + navigation: { pathname: `/group/${GROUP_ID}` }, }, }, tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { groupInfo: defaultGroupInfo }, decorators: [ (Story) => ( - + ), ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { parameters: { docs: { description: { - story: '그룹 헤더 - 기본 뷰', + story: ` +그룹 헤더 기본 뷰 — ADMIN 권한으로 모든 메뉴 표시. + +- **초대 버튼 클릭**: 멤버 초대 드로어가 열려 초대 코드 생성 및 공유 가능 +- **달력 아이콘 클릭**: 날짜 선택 드로어로 특정 날짜의 기록 이동 +- **더보기(⋮) 클릭**: 그룹 정보 수정 / 나의 그룹 프로필 / 멤버 관리 / 그룹 나가기 팝오버 표시 + `, }, }, }, }; export const LongGroupName: Story = { + args: { groupInfo: longNameGroupInfo }, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { docs: { description: { - story: '그룹 이름이 매우 긴 경우', + story: '그룹 이름이 매우 긴 경우 — 말줄임표 처리 확인', }, }, - layout: 'padded', - nextjs: { - navigation: { pathname: '/group/long-name' }, - }, - }, -}; - -export const ManyMembers: Story = { - parameters: { - layout: 'padded', nextjs: { - navigation: { pathname: '/group/many-members' }, - }, - docs: { - description: { - story: '멤버가 5명 이상인 경우', - }, + navigation: { pathname: `/group/${LONG_NAME_GROUP_ID}` }, }, }, -}; +} -export const Interactive: Story = { +export const ViewerRole: Story = { + args: { groupInfo: defaultGroupInfo }, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { docs: { description: { - story: ` -- **멤버 아바타 클릭**: 멤버 목록 상세 또는 프로필 페이지로 이동 -- **멤버 +N 표시**: 최대 노출 수 초과 시 나머지 멤버 수를 +N으로 축약 표시 -- **설정 버튼 클릭**: 그룹 설정 페이지로 이동 -- **그룹 이름 말줄임**: 이름이 길면 말줄임 처리(LongGroupName 스토리 참고) - `, + story: 'VIEWER 권한 — 초대 버튼이 숨겨지고 그룹 정보 수정·멤버 관리 메뉴가 비표시', }, }, }, }; - From 8ab45498bc70d63bbc77c60f8962f9b66ba91774 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 8 Jun 2026 18:02:25 +0900 Subject: [PATCH 34/39] =?UTF-8?q?fix:=20=EC=B4=88=EB=8C=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=B4=88=EB=8C=80=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inviteCode가 없는 경우에도 isInviteError와 동일하게 에러 상태로 처리 - 초대 코드 없이 접근 시 '유효하지 않은 초대 링크'메시지 표시 및 수락 버튼 비활성화 --- frontend/src/app/(invite)/invite/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/(invite)/invite/page.tsx b/frontend/src/app/(invite)/invite/page.tsx index 86954ee1..6631590a 100644 --- a/frontend/src/app/(invite)/invite/page.tsx +++ b/frontend/src/app/(invite)/invite/page.tsx @@ -32,6 +32,9 @@ export default function InvitePage() { isLoading, isError: isInviteError, } = useGetInviteInfo(inviteCode || ''); + + // 초대 코드가 없거나 유효하지 않으면 에러 상태로 처리 + const isInvalidInvite = !inviteCode || isInviteError; const { data: profile } = useQuery({ ...userProfileOptions(), enabled: isLoggedIn && userType === 'social', @@ -159,7 +162,7 @@ export default function InvitePage() {
- ) : isInviteError ? ( + ) : isInvalidInvite ? (

유효하지 않은 초대 링크 @@ -209,7 +212,7 @@ export default function InvitePage() {