diff --git a/.gitignore b/.gitignore
index 2189378db..2ba50d090 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/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..fda76bc4f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,52 @@
+# 프로젝트 개요
+
+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` 설명은 한국어로, 비즈니스 언어 사용
+
+---
+
+# 프론트엔드 Storybook 가이드라인
+
+자세한 내용은 [STORYBOOK.md](./STORYBOOK.md) 참고.
+
+## 요약
+
+- 스토리 파일 위치: `src/**/storybook/*.stories.tsx`
+- 모든 스토리에 `docs.description.component`(컴포넌트 용도·동작)와 `docs.description.story`(스토리 상태 설명) 작성
+- `experimentalRSC: true` 사용 금지 — Controls(args) 변경이 화면에 반영되지 않음
+- Controls로 prop을 제어하는 스토리는 `render: (args) => `로 args를 직접 전달
+- QueryClient는 모듈 레벨에서 한 번만 생성 (decorator 안에서 생성 금지)
+- mock 데이터는 `@/lib/mocks/mock.ts`에서 가져온다 (`handlers.ts` 직접 import 금지)
diff --git a/STORYBOOK.md b/STORYBOOK.md
new file mode 100644
index 000000000..1ac36a588
--- /dev/null
+++ b/STORYBOOK.md
@@ -0,0 +1,331 @@
+# 프론트엔드 Storybook 작성 가이드라인
+
+> `frontend/` 전용 가이드라인. 프레임워크: `@storybook/nextjs-vite` + MSW + TanStack Query.
+
+---
+
+## 1. Storybook의 역할
+
+Storybook은 컴포넌트를 **실제 페이지 맥락 없이** 독립적으로 확인하고 문서화하는 도구다.
+
+| 용도 | 설명 |
+|------|------|
+| **시각적 문서화** | 컴포넌트가 어디서 쓰이는지, 어떤 상태가 존재하는지 한눈에 파악 |
+| **인터랙션 확인** | Controls 패널로 props를 바꿔가며 렌더링 결과를 즉시 확인 |
+| **회귀 방지** | Chromatic 등 비주얼 회귀 도구와 연동해 의도치 않은 UI 변경 감지 |
+
+---
+
+## 2. 파일 구조 및 네이밍
+
+```
+src/
+ app/
+ (main)/
+ _components/
+ RecordList.tsx
+ storybook/
+ RecordList.stories.tsx ← 컴포넌트와 같은 도메인 폴더 안
+ components/
+ BlockContent.tsx
+ storybook/
+ BlockContent.stories.tsx
+```
+
+- 파일명: `{컴포넌트명}.stories.tsx`
+- 위치: 컴포넌트 파일과 같은 디렉토리의 `storybook/` 하위 폴더
+- `title`: `'도메인/컴포넌트명'` 형식 (예: `'Record/RecordList'`, `'Calendar/WeekCalendar'`)
+
+---
+
+## 3. 설명(description) 작성 규칙
+
+모든 스토리 파일은 **두 레벨**의 설명을 포함한다.
+
+### 3-1. 컴포넌트 레벨 — `docs.description.component` (meta에 작성)
+
+Docs 탭 상단에 표시된다. 다음 내용을 마크다운으로 작성한다.
+
+- **어디서 쓰이는지**: 어느 페이지·탭·섹션에서 렌더링되는지
+- **주요 동작**: 사용자가 상호작용하면 무슨 일이 일어나는지
+- **주요 prop**: 시각적 변화를 일으키는 prop이 있으면 한 줄 설명
+
+```tsx
+const meta = {
+ title: 'Record/RecordList',
+ component: RecordList,
+ parameters: {
+ docs: {
+ description: {
+ component: `
+홈 화면과 그룹 홈의 **피드 탭**에서 날짜별 기록 목록을 표시하는 컴포넌트.
+
+WeekCalendar에서 날짜를 선택하면 해당 날짜의 기록이 이 컴포넌트를 통해 렌더링된다.
+카드를 클릭하면 기록 상세 페이지(\`/record/:id\`)로 이동한다.
+ `,
+ },
+ },
+ },
+} satisfies Meta;
+```
+
+### 3-2. 스토리 레벨 — `docs.description.story` (각 Story에 작성)
+
+| 스토리 | 설명 내용 |
+|--------|-----------|
+| **Default** | 컴포넌트의 주요 동작과 클릭 이벤트를 불릿으로 함께 기술 |
+| 그 외 상태 | 해당 상태가 언제 보이는지 한 줄 |
+
+**Interactive 스토리는 따로 만들지 않는다.** 클릭 이벤트·드로어·팝오버 동작 설명은 Default 스토리의 `docs.description.story`에 함께 작성한다. 별도 Interactive 스토리는 Default와 내용이 중복되고, 읽는 사람이 두 곳을 오가야 하는 불편이 생긴다.
+
+```tsx
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: `
+오늘 날짜 기준 기록 목록 — 개인/그룹 기록이 혼합된 기본 상태.
+
+- **카드 클릭**: 기록 상세 페이지(\`/record/:id\`)로 이동
+- **imageLayout 변경** (Controls 패널):
+ - \`tile\` — 이미지 그리드로 한 번에 표시
+ - \`carousel\` — 이미지를 한 장씩 슬라이드로 표시
+ `,
+ },
+ },
+ },
+};
+```
+
+---
+
+## 4. Controls(args) 작동 규칙
+
+### 4-1. `experimentalRSC` 비활성화 유지
+
+`.storybook/main.ts`에서 `experimentalRSC: true`를 설정하면 Controls(args) 변경 시
+서버 재렌더링이 트리거되지 않아 prop 제어가 동작하지 않는다.
+**실제 서버 컴포넌트(RSC) 스토리가 없는 한 이 옵션은 활성화하지 않는다.**
+
+### 4-2. `render(args)` 패턴으로 props 직접 전달
+
+Controls로 prop을 제어할 때는 `render` 함수의 첫 번째 인자 `args`를 컴포넌트에 직접 넘긴다.
+
+```tsx
+export const Default: Story = {
+ args: { imageLayout: 'tile' },
+ // ✅ args를 컴포넌트에 직접 전달 — Controls 변경 시 즉시 반영됨
+ render: (args) => ,
+};
+```
+
+`useArgs()` 훅은 사용하지 않는다 — Storybook hook 컨텍스트 제약이 까다롭고, `render(args)` 패턴으로 충분히 해결된다.
+
+---
+
+## 5. 다크 모드 패턴
+
+### 5-1. DarkMode 스토리를 만들지 않는다
+
+`preview.tsx`의 전역 데코레이터가 모든 스토리에 ThemeProvider를 제공하고,
+우상단 툴바의 **Light / Dark 토글**로 어떤 스토리든 즉시 테마를 전환할 수 있다.
+별도 DarkMode 스토리는 중복이다.
+
+특정 스토리를 다크 모드로 시작하고 싶다면 `parameters.globals.theme`을 지정한다.
+
+```tsx
+// ✅ 스토리 초기 테마 지정
+export const SomeDarkFirst: Story = {
+ parameters: {
+ globals: { theme: 'dark' },
+ backgrounds: { default: 'dark' },
+ },
+};
+
+// ❌ DarkMode 스토리를 별도로 만들지 않는다
+export const DarkMode: Story = { ... };
+```
+
+### 5-2. 전역 ThemeProvider — 스토리에서 따로 감싸지 않는다
+
+`preview.tsx`가 전역으로 `ThemeProvider`를 제공하므로, 개별 스토리에서 `ThemeProvider`를 다시 감쌀 필요 없다. `forcedTheme={context.globals.theme}`으로 스토리 간 `html.dark` 오염도 차단된다.
+
+```tsx
+// preview.tsx — 전역 설정 (건드리지 않는다)
+const withTheme: Decorator = (Story, context) => {
+ const theme = context.globals.theme ?? 'light';
+ return (
+
+
+
+ );
+};
+```
+
+### 5-3. wrapper div에 `dark:bg-` 추가
+
+스토리의 배경이 툴바 테마 전환에 반응하도록 wrapper div에 항상 dark 배경을 함께 지정한다.
+
+```tsx
+// ✅ 툴바 전환 시 배경도 함께 반응
+
+```
+
+---
+
+## 6. MSW + TanStack Query 패턴
+
+### 6-1. QueryClient는 모듈 레벨에서 한 번만 생성
+
+decorator 함수 안에서 QueryClient를 생성하면 args 변경 때마다 새 인스턴스가 만들어져 loading 플래시가 발생한다.
+
+```tsx
+// ✅ 모듈 레벨에서 한 번만 생성
+const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+});
+```
+
+### 6-2. 내부에서 데이터를 fetch하는 컴포넌트 — pre-seed 패턴
+
+`useSuspenseQuery` / `useQuery` / `useInfiniteQuery`를 **컴포넌트 내부에서 직접 호출**하는 경우,
+MSW 인터셉트 대신 `client.setQueryData`로 캐시를 미리 채운다.
+Storybook에서 인증 헤더가 없어 API 호출이 실패하는 문제를 피할 수 있고,
+데이터가 즉시 표시되어 로딩 플래시도 없다.
+
+```tsx
+// ✅ pre-seed 패턴 (useSuspenseQuery 컴포넌트)
+function makeClient() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ // queryKey는 실제 queryOptions의 queryKey와 정확히 일치해야 한다
+ client.setQueryData(['profile', 'me'], mockProfile);
+ return client;
+}
+
+const clients = {
+ default: makeClient(),
+ // 스토리마다 독립적인 QueryClient 인스턴스 사용
+};
+
+export const Default: Story = {
+ decorators: [
+ (Story) => (
+
+ 로딩 중...
}>
+
+
+
+ ),
+ ],
+};
+```
+
+`useSuspenseQuery`를 사용하는 컴포넌트는 반드시 ``로 감싼다.
+
+### 6-3. MSW 핸들러는 각 스토리의 `parameters.msw.handlers`에 선언
+
+외부 API를 mock할 때(인증이 필요 없는 경우)는 MSW를 사용한다.
+스토리별로 다른 응답이 필요하면 각 스토리에서 핸들러를 덮어쓴다.
+
+```tsx
+export const EmptyRecords: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/feed', () =>
+ HttpResponse.json({ success: true, data: [], error: null }),
+ ),
+ ],
+ },
+ },
+};
+```
+
+### 6-4. mock 데이터는 `src/lib/mocks/mock.ts`에서 가져온다
+
+```tsx
+// ✅
+import { createMockRecordPreviews } from '@/lib/mocks/mock';
+
+// ❌ handlers.ts는 mock 함수를 re-export하지 않음
+import { createMockRecordPreviews } from '@/lib/mocks/handlers';
+```
+
+---
+
+## 7. 스토리 구성 순서
+
+같은 파일 안에서 스토리를 아래 순서로 배치한다.
+
+1. **Default** — 가장 일반적인 상태 + 주요 인터랙션 설명 포함
+2. **상태 변형** — 데이터 개수, 조건 분기, 권한 차이 등 (예: `EmptyRecords`, `ViewerRole`)
+3. **그룹/개인 분기** — 그룹/개인 모드가 있는 경우 (예: `GroupRecords`)
+
+---
+
+## 8. 공통 원칙
+
+| 원칙 | 내용 |
+|------|------|
+| **한국어 설명** | `docs.description`은 한국어로 작성 — 비즈니스 언어 사용 |
+| **mock은 현실에 가깝게** | 실제 API 응답 타입과 필드명을 정확히 사용 |
+| **props 변화가 보이게** | imageLayout 같은 prop은 2장 이상의 이미지 mock으로 차이가 보이도록 |
+| **`inline: false` 지양** | Docs 뷰에서 iframe이 생성되어 툴바 globals(theme 등)가 전달되지 않을 수 있음 |
+
+---
+
+## 9. 실전 교훈
+
+### 9-1. 컴포넌트 props 타입을 반드시 확인한다
+
+스토리 작성 전에 컴포넌트의 실제 props 인터페이스를 확인한다.
+타입이 다르면 런타임 에러가 발생하거나 데이터가 렌더링되지 않는다.
+
+```ts
+// ❌ ProfileTag(객체)를 넘겼으나 컴포넌트는 TagStatSummary를 기대함
+args: { tags: { recent: [...], frequent: [...], all: [] } }
+
+// ✅ 실제 타입 확인 후 적용
+args: { tags: { recentTags: [...], frequentTags: [...] } }
+```
+
+필드명도 컴포넌트 코드에서 직접 확인한다 (`tag.name` vs `tag.tag` 등).
+
+### 9-2. 필수 props 누락은 즉시 런타임 에러로 나타난다
+
+`me`, `groupId` 같은 필수 prop을 빠뜨리면 컴포넌트 내부에서 `undefined.role` 형태의 에러가 발생한다. 에러 메시지의 컴포넌트명을 보고 해당 컴포넌트의 props 인터페이스를 확인한다.
+
+### 9-3. `groupName` 등 선택적 필드를 빠뜨리면 UI가 비어 보인다
+
+`scope: 'GROUP'`인 기록의 mock에 `groupName`을 넣지 않으면 `{record.groupName}` 렌더가 빈 문자열로 표시된다. mock 작성 시 컴포넌트가 실제로 렌더링하는 필드를 빠짐없이 채운다.
+
+### 9-4. `__dirname is not defined` — `viteFinal`에서 define 설정
+
+`next-auth` 내부 의존(`ua-parser-js`)이 CJS 빌드에서 `__dirname`을 사용한다.
+`.storybook/main.ts`의 `viteFinal`에서 `__dirname`을 빈 문자열로 주입한다.
+
+```ts
+viteFinal: async (config) => {
+ config.define = { ...config.define, __dirname: JSON.stringify('') };
+ return config;
+},
+```
+
+### 9-5. SVG 기반 차트는 `resolvedTheme` + `key`로 다크 모드 적용
+
+Recharts 등 SVG 기반 차트의 tick/label 색상은 Tailwind `dark:` 클래스로 제어할 수 없다.
+`useTheme().resolvedTheme`으로 색상을 분기하고, `key={resolvedTheme}`으로 테마 변경 시 차트를 리마운트한다.
+
+```tsx
+const { resolvedTheme } = useTheme();
+
+
+
+
+
+
+```
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 000000000..5bd2b06b2
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,241 @@
+# 프론트엔드 테스트 코드 작성 가이드라인
+
+> `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. **엣지 케이스** — 권한 없는 접근, 네트워크 오류 처리
+
+---
+
+## 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/backend/src/modules/map/map.service.ts b/backend/src/modules/map/map.service.ts
index 2ba46a7f2..6df8a5e94 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/.gitignore b/frontend/.gitignore
index e92bdf67d..05d766ca6 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -54,4 +54,9 @@ storybook-static
.env.sentry-build-plugin
# lighthouse
-.lighthouseci/
\ No newline at end of file
+.lighthouseci/e2e/.auth/
+playwright-report/
+test-results/
+
+# playwright auth state (setup 단계에서 생성되는 세션 토큰)
+e2e/.auth/
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 9d5519c99..7e5ca2ec8 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -12,8 +12,17 @@ const config: StorybookConfig = {
],
framework: '@storybook/nextjs-vite',
staticDirs: ['../public'],
- features: {
- experimentalRSC: true,
+ // experimentalRSC: true 는 Controls(args) 변경 시 서버 재렌더링이 트리거되지 않아
+ // imageLayout 같은 prop 제어가 동작하지 않는 문제를 일으킨다.
+ // 실제 RSC 스토리가 없으므로 비활성화한다.
+ viteFinal: async (config) => {
+ // next-auth의 ua-parser-js(CJS 빌드)가 __dirname을 사용하는데,
+ // Vite가 브라우저 번들로 변환할 때 정의되지 않아 ReferenceError가 발생함.
+ config.define = {
+ ...config.define,
+ __dirname: JSON.stringify(''),
+ };
+ return config;
},
};
export default config;
diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts
deleted file mode 100644
index b6a84dc03..000000000
--- a/frontend/.storybook/preview.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Preview } from '@storybook/nextjs-vite';
-import { initialize, mswLoader } from 'msw-storybook-addon';
-import '../src/app/globals.css';
-
-// MSW 초기화
-initialize();
-
-const preview: Preview = {
- parameters: {
- controls: {
- matchers: {
- color: /(background|color)$/i,
- date: /Date$/i,
- },
- },
-
- nextjs: {
- appDirectory: true,
- navigation: {
- segments: ['groupId', 'month', 'year', 'date', 'userId', 'recordId'],
- },
- },
-
- a11y: {
- // 'todo' - show a11y violations in the test UI only
- // 'error' - fail CI on a11y violations
- // 'off' - skip a11y checks entirely
- test: 'todo',
- },
- },
- loaders: [mswLoader],
-};
-
-export default preview;
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
new file mode 100644
index 000000000..864bd3387
--- /dev/null
+++ b/frontend/.storybook/preview.tsx
@@ -0,0 +1,57 @@
+import type { Preview, Decorator } from '@storybook/nextjs-vite';
+import { ThemeProvider } from 'next-themes';
+import { initialize, mswLoader } from 'msw-storybook-addon';
+import '../src/app/globals.css';
+
+initialize();
+
+// 모든 스토리를 ThemeProvider로 감싸 useTheme()이 어디서든 동작하게 한다.
+// forcedTheme을 Storybook 글로벌 theme 값으로 고정해 스토리 간 html.dark 오염을 차단한다.
+// 각 스토리는 parameters.globals.theme으로 초기 테마를 지정하고,
+// Storybook 툴바(우상단 테마 토글)로 언제든 전환할 수 있다.
+const withTheme: Decorator = (Story, context) => {
+ const theme = (context.globals.theme as string) ?? 'light';
+ return (
+
+
+
+ );
+};
+
+const preview: Preview = {
+ globalTypes: {
+ theme: {
+ name: 'Theme',
+ defaultValue: 'light',
+ toolbar: {
+ icon: 'circlehollow',
+ items: [
+ { value: 'light', title: 'Light' },
+ { value: 'dark', title: 'Dark' },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ segments: ['groupId', 'month', 'year', 'date', 'userId', 'recordId'],
+ },
+ },
+ a11y: {
+ test: 'todo',
+ },
+ },
+ decorators: [withTheme],
+ loaders: [mswLoader],
+};
+
+export default preview;
diff --git a/frontend/e2e/announcement-modal.spec.ts b/frontend/e2e/announcement-modal.spec.ts
new file mode 100644
index 000000000..b655ba359
--- /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
new file mode 100644
index 000000000..ea75de579
--- /dev/null
+++ b/frontend/e2e/announcements.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from '@playwright/test';
+
+const ADMIN_KEY = 'ittda_admin';
+const BACKEND = 'http://localhost:4000/v1';
+const TEST_TITLE = 'E2E 테스트 공지사항';
+
+// 공지사항 페이지는 서버 컴포넌트(RSC)로 백엔드에서 직접 fetch.
+// beforeAll에서 admin API로 활성 공지를 생성하고 afterAll에서 삭제한다.
+test.describe('공지사항', () => {
+ let announcementId: string;
+
+ test.beforeAll(async ({ request }) => {
+ const res = await request.post(`${BACKEND}/admin/announcements`, {
+ headers: { 'x-admin-key': ADMIN_KEY },
+ data: { title: TEST_TITLE, isActive: true },
+ });
+ const body = await res.json();
+ announcementId = body?.id ?? body?.data?.id;
+ });
+
+ test.afterAll(async ({ request }) => {
+ if (!announcementId) return;
+ await request.delete(`${BACKEND}/admin/announcements/${announcementId}`, {
+ headers: { 'x-admin-key': ADMIN_KEY },
+ });
+ });
+
+ 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: '공지사항', exact: true })).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 });
+ });
+
+ test('공지사항이 있을 때 각 항목에 제목과 날짜가 표시된다', async ({ page }) => {
+ await page.goto('/announcements');
+
+ const firstCard = page.locator('main > div').first();
+ await expect(firstCard.locator('h3').first()).toBeVisible({ timeout: 8000 });
+ await expect(firstCard.locator('h3').first()).not.toBeEmpty();
+ await expect(firstCard.getByText(/\d{4}년/).first()).toBeVisible();
+ });
+
+ test('진행 중인 공지에는 "진행 중" 배지가 표시된다', async ({ page }) => {
+ await page.goto('/announcements');
+
+ await expect(page.getByText('진행 중').first()).toBeVisible({ timeout: 8000 });
+ });
+});
diff --git a/frontend/e2e/date-restriction.spec.ts b/frontend/e2e/date-restriction.spec.ts
new file mode 100644
index 000000000..02889e865
--- /dev/null
+++ b/frontend/e2e/date-restriction.spec.ts
@@ -0,0 +1,180 @@
+import { test, expect } from '@playwright/test';
+import path from 'path';
+import { RecordEditorPage } from './pages/RecordEditorPage';
+import { createTestRecord, deleteTestRecord } from './fixtures/api';
+
+// 로컬 날짜 기준으로 어제 계산 (WeekCalendar는 로컬 날짜를 사용)
+const yesterdayDate = new Date();
+yesterdayDate.setDate(yesterdayDate.getDate() - 1);
+const YESTERDAY = `${yesterdayDate.getFullYear()}-${String(yesterdayDate.getMonth() + 1).padStart(2, '0')}-${String(yesterdayDate.getDate()).padStart(2, '0')}`;
+const YESTERDAY_DAY = String(yesterdayDate.getDate());
+
+const TODAY = new Date().toISOString().split('T')[0];
+
+test.describe('미래 날짜 선택 불가', () => {
+ test('기록 생성 시 미래 날짜는 선택할 수 없다', async ({ page }) => {
+ // 에디터 진입
+ const editor = new RecordEditorPage(page);
+ await editor.goto({ mode: 'add', date: TODAY });
+
+ // 날짜 블록의 DateField 버튼 클릭 → DateDrawer 오픈
+ // DateField:
+ const dateBlock = page.locator('[data-block-id]').first();
+ const dateFieldButton = dateBlock.getByRole('button').first();
+ await expect(dateFieldButton).toBeVisible({ timeout: 5000 });
+
+ // Drawer 열기 전에 현재 날짜 텍스트 저장
+ const selectedDateText = await dateFieldButton.textContent();
+ await dateFieldButton.click();
+
+ // DateDrawer 열림 확인 — DrawerTitle: "언제의 기록인가요?"
+ await expect(page.getByText('언제의 기록인가요?')).toBeVisible({ timeout: 5000 });
+
+ // 캘린더 그리드 안의 disabled 버튼 = 미래 날짜
+ // DateDrawer: isFuture → disabled={true} + opacity-20 + cursor-not-allowed
+ const futureDateButton = page.locator('button[disabled]').filter({ hasText: /^\d+$/ }).first();
+ await expect(futureDateButton).toBeVisible({ timeout: 5000 });
+
+ // disabled 속성 확인 → 선택 불가 상태
+ await expect(futureDateButton).toBeDisabled();
+
+ // disabled 버튼 클릭 시도 — HTML disabled는 이벤트를 차단하므로 날짜가 바뀌지 않음
+ await futureDateButton.scrollIntoViewIfNeeded();
+ await futureDateButton.click({ force: true });
+
+ // Drawer가 닫히지 않고 여전히 열려있어야 함 → 날짜 선택이 발생하지 않았음을 증명
+ await expect(page.getByText('언제의 기록인가요?')).toBeVisible();
+
+ // Drawer 닫기 후 날짜 텍스트가 변경되지 않았는지 확인
+ await page.keyboard.press('Escape');
+ await expect(dateFieldButton).toBeVisible({ timeout: 3000 });
+ await expect(dateFieldButton).toHaveText(selectedDateText!);
+ });
+
+ test('날짜 drawer로 기록 탐색 시 미래 날짜는 선택할 수 없다', async ({ page }) => {
+ await page.goto('/');
+
+ // WeekCalendar — 주간 날짜 버튼들이 있는 영역
+ // 미래 날짜: disabled={true} + opacity-40 + cursor-not-allowed
+ const weekCalendar = page.locator('div[class*="overflow-hidden"]').filter({
+ has: page.locator('button[disabled]'),
+ }).first();
+
+ // 미래 날짜 버튼 (disabled) 이 존재하는지 확인
+ // 오늘이 주말 마지막 날이 아닌 이상 반드시 미래 날짜가 있음
+ const futureWeekButton = weekCalendar.locator('button[disabled]').first();
+ const hasFutureButton = await futureWeekButton.isVisible({ timeout: 5000 }).catch(() => false);
+
+ if (!hasFutureButton) {
+ // 오늘이 토요일(주의 마지막 날) — 다음 주로 이동해 미래 날짜 disabled 검증
+ const nextWeekButton = page.locator('button').filter({ has: page.locator('[data-lucide="chevron-right"]') }).last();
+ const hasNext = await nextWeekButton.isVisible({ timeout: 3000 }).catch(() => false);
+ if (!hasNext) {
+ test.skip(); // 다음 주 이동 버튼이 없는 예외 상황
+ return;
+ }
+ await nextWeekButton.click();
+
+ // 다음 주는 모든 날짜가 미래 → 첫 번째 버튼이 disabled 상태여야 함
+ const nextFutureButton = weekCalendar.locator('button[disabled]').first();
+ await expect(nextFutureButton).toBeVisible({ timeout: 5000 });
+ await expect(nextFutureButton).toBeDisabled();
+ await nextFutureButton.click({ force: true });
+ await expect(nextFutureButton).toBeDisabled();
+ return;
+ }
+
+ // disabled 속성 확인 → 선택 불가 상태
+ await expect(futureWeekButton).toBeDisabled();
+
+ // 선택된 날짜 확인 (오늘 날짜가 기본 선택)
+ // 클릭 시도 후 날짜가 변경되지 않는지 확인
+ const todayButton = weekCalendar.locator('button').filter({
+ has: page.locator('[class*="itta-point"], [class*="green"]'),
+ }).first();
+ const todayVisible = await todayButton.isVisible({ timeout: 2000 }).catch(() => false);
+
+ // 미래 날짜 클릭 시도
+ await futureWeekButton.click({ force: true });
+
+ // 미래 날짜를 클릭해도 오늘 날짜가 여전히 선택 상태 유지
+ if (todayVisible) {
+ await expect(todayButton).toBeVisible();
+ }
+
+ // disabled 버튼이 여전히 disabled 상태임을 확인
+ await expect(futureWeekButton).toBeDisabled();
+ });
+});
+
+const PAST_RECORD_TITLE = 'E2E 과거날짜 기록';
+
+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, PAST_RECORD_TITLE, YESTERDAY);
+ 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();
+ });
+
+ // WeekCalendar 컨테이너 — 요일명 span이 있는 overflow-hidden div
+ const getWeekCalendar = (page: Parameters[1] extends { page: infer P } ? P : import('@playwright/test').Page) =>
+ page.locator('div[class*="overflow-hidden"]').filter({
+ has: page.locator('span', { hasText: /^[일월화수목금토]$/ }),
+ }).first();
+
+ test('과거 날짜로 저장한 기록은 오늘 날짜 홈에서 표시되지 않는다', async ({ page }) => {
+ await page.goto('/');
+
+ // WeekCalendar가 보이면 홈이 렌더링된 상태
+ await expect(getWeekCalendar(page)).toBeVisible({ timeout: 5000 });
+
+ // 오늘 기록 목록에 과거 날짜 기록이 없어야 함
+ await expect(page.getByText(PAST_RECORD_TITLE)).not.toBeVisible();
+ });
+
+ test('홈 주간 달력에서 과거 날짜를 선택하면 해당 기록이 표시된다', async ({ page }) => {
+ await page.goto('/');
+
+ const weekCalendar = getWeekCalendar(page);
+ await expect(weekCalendar).toBeVisible({ timeout: 5000 });
+
+ // 어제 날짜 버튼 찾기 (오늘이 일요일이면 어제는 이전 주)
+ const yesterdayButton = weekCalendar.locator('button').filter({
+ hasText: new RegExp(`^${YESTERDAY_DAY}$`),
+ }).first();
+
+ const isInCurrentWeek = await yesterdayButton.isVisible({ timeout: 3000 }).catch(() => false);
+
+ if (!isInCurrentWeek) {
+ // 오늘이 일요일 — 어제(토요일)는 이전 주
+ // cursor-grab div(Framer Motion 드래그 영역)를 오른쪽으로 드래그해 이전 주로 이동
+ const swipeArea = weekCalendar.locator('[class*="cursor-grab"]').first();
+ const box = await swipeArea.boundingBox();
+ if (box) {
+ const y = box.y + box.height / 2;
+ await page.mouse.move(box.x + box.width * 0.25, y);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width * 0.75, y, { steps: 20 });
+ await page.mouse.up();
+ }
+ // 이전 주가 렌더링되어 어제 버튼이 나타날 때까지 대기
+ await expect(yesterdayButton).toBeVisible({ timeout: 3000 });
+ }
+
+ await yesterdayButton.click();
+
+ // 해당 날짜의 기록이 표시됨 (.first()로 이전 테스트 실행 잔여 데이터 허용)
+ await expect(page.getByText(PAST_RECORD_TITLE).first()).toBeVisible({ timeout: 15000 });
+ });
+});
diff --git a/frontend/e2e/date-selector-drawer.spec.ts b/frontend/e2e/date-selector-drawer.spec.ts
new file mode 100644
index 000000000..c53717f3a
--- /dev/null
+++ b/frontend/e2e/date-selector-drawer.spec.ts
@@ -0,0 +1,99 @@
+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');
+ await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
+ // "내 기록함" 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 Promise.all([
+ page.waitForURL(new RegExp(`/my/detail/${TODAY_STR}`), { timeout: 10000 }),
+ page
+ .locator('[role="dialog"]')
+ .getByRole('button', { name: String(TODAY_DAY), exact: true })
+ .click(),
+ ]);
+
+ // 오늘 생성한 기록 타이틀 확인 (타임라인에서 같은 타이틀이 여러 요소로 렌더링될 수 있어 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 Promise.all([
+ page.waitForURL(new RegExp(`/my/month/${PREV_YEAR_MONTH}`), { timeout: 10000 }),
+ page.getByText('월 기록 전체보기').click(),
+ ]);
+ });
+
+ test('년도 기록 전체보기를 클릭하면 해당 년도의 기록함으로 이동한다', async ({ page }) => {
+ await openDateDrawer(page);
+
+ await Promise.all([
+ page.waitForURL(new RegExp(`/my/year/${CURRENT_YEAR}`), { timeout: 10000 }),
+ page.getByText(`${CURRENT_YEAR}년 기록 전체보기`).click(),
+ ]);
+ });
+});
diff --git a/frontend/e2e/fixtures/api.ts b/frontend/e2e/fixtures/api.ts
new file mode 100644
index 000000000..6b34cb407
--- /dev/null
+++ b/frontend/e2e/fixtures/api.ts
@@ -0,0 +1,282 @@
+import { Page, Browser, BrowserContext } 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 };
+}
+
+/**
+ * 새 게스트 계정을 생성하고 auth setup과 동일하게 쿠키 + localStorage를 세팅한 BrowserContext를 반환한다.
+ */
+export async function createGuestSession(browser: Browser): Promise {
+ const ctx = await browser.newContext({ baseURL: 'http://localhost:3000' });
+ const page = await ctx.newPage();
+
+ const res = await page.request.post('/api/auth/guest');
+ const body = await res.json();
+ const authHeader = res.headers()['authorization'] ?? '';
+ const token = authHeader.replace('Bearer ', '');
+ const sessionId = body?.data?.guestSessionId ?? '';
+
+ if (!body.success || !token || !sessionId) throw new Error('게스트 세션 생성 실패');
+
+ // 쿠키 설정 (auth.setup.ts와 동일)
+ await ctx.addCookies([
+ { name: 'x-guest-session-id', value: sessionId, domain: 'localhost', path: '/' },
+ { name: 'x-guest-access-token', value: token, domain: 'localhost', path: '/' },
+ ]);
+
+ // localStorage에 auth-storage 주입 (Zustand persist 스토어가 이 값을 읽음)
+ 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, token },
+ );
+
+ await page.close();
+ return ctx;
+}
+
+/**
+ * 초대 코드로 그룹에 참여한다.
+ */
+export async function joinTestGroup(page: Page, inviteCode: string): Promise {
+ const headers = await getAuthHeaders(page);
+ const res = await page.request.post(`/api/groups/invites/${inviteCode}/join`, { headers, data: {} });
+ const body = await res.json();
+ if (!body.success) throw new Error(`그룹 참여 실패: ${JSON.stringify(body.error)}`);
+}
+
+/**
+ * 공동 기록 draft를 생성하고 draftId를 반환한다.
+ */
+export async function createTestDraft(page: Page, groupId: string): Promise {
+ const headers = await getAuthHeaders(page);
+ const res = await page.request.get(`/api/groups/${groupId}/posts/new`, { headers });
+ const body = await res.json();
+ if (!body.success) throw new Error(`드래프트 생성 실패: ${JSON.stringify(body.error)}`);
+ const redirectUrl: string = body.data.redirectUrl;
+ return redirectUrl.split('/').pop() ?? '';
+}
+
+/**
+ * 테스트용 그룹을 삭제한다.
+ */
+export async function deleteTestGroup(
+ page: Page,
+ groupId: string,
+): Promise {
+ 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 000000000..58e711bea
--- /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-draft-collaboration.spec.ts b/frontend/e2e/group-draft-collaboration.spec.ts
new file mode 100644
index 000000000..9fc8e1ca9
--- /dev/null
+++ b/frontend/e2e/group-draft-collaboration.spec.ts
@@ -0,0 +1,246 @@
+import { test, expect } from '@playwright/test';
+import path from 'path';
+import {
+ createTestGroup,
+ deleteTestGroup,
+ createGuestSession,
+ joinTestGroup,
+ createTestDraft,
+ deleteTestRecord,
+} from './fixtures/api';
+
+const DRAFT_TEXT = 'E2E 공동 기록 실시간 텍스트';
+const LOCK_RELEASE_TEXT = 'B가 락 해제 후 편집한 텍스트';
+const TITLE_TEXT = 'E2E 공동기록 제목 동기화';
+
+async function openDraftAsTwoMembers(
+ browser: import('@playwright/test').Browser,
+ groupId: string,
+ inviteCode: string,
+ draftId: string,
+) {
+ const ctxA = await browser.newContext({
+ storageState: path.join(__dirname, '.auth/guest.json'),
+ });
+ const ctxB = await createGuestSession(browser);
+ const pageA = await ctxA.newPage();
+ const pageB = await ctxB.newPage();
+
+ await joinTestGroup(pageB, inviteCode);
+
+ const draftUrl = `/group/${groupId}/post/${draftId}`;
+ await Promise.all([pageA.goto(draftUrl), pageB.goto(draftUrl)]);
+
+ const textInputA = pageA.getByPlaceholder('어떤 기억이 있으신가요?');
+ const textInputB = pageB.getByPlaceholder('어떤 기억이 있으신가요?');
+ await expect(textInputA).toBeVisible({ timeout: 15000 });
+ await expect(textInputB).toBeVisible({ timeout: 15000 });
+
+ // 두 소켓이 같은 draft room에 완전히 연결됨을 보장
+ await expect(pageA.getByText(/2명.*편집 중/)).toBeVisible({ timeout: 15000 });
+ await expect(pageB.getByText(/2명.*편집 중/)).toBeVisible({ timeout: 15000 });
+
+ return { ctxA, ctxB, pageA, pageB, textInputA, textInputB };
+}
+
+test.describe('공동 기록 실시간 협업', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let groupId: string;
+ let inviteCode: string;
+ let draftId: string;
+
+ test.beforeAll(async ({ browser }) => {
+ // 멤버 A: 기존 게스트 계정으로 그룹 생성
+ 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: 새 게스트 계정으로 그룹 참여
+ const ctxB = await createGuestSession(browser);
+ const pageB = await ctxB.newPage();
+ await joinTestGroup(pageB, inviteCode);
+
+ // 멤버 A가 draft 생성
+ draftId = await createTestDraft(pageA, groupId);
+
+ await ctxA.close();
+ await ctxB.close();
+ });
+
+ test.afterAll(async ({ browser }) => {
+ const ctxA = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') });
+ const pageA = await ctxA.newPage();
+ await deleteTestGroup(pageA, groupId);
+ await ctxA.close();
+ });
+
+ test('공동 기록 draft 생성 시 그룹 홈 floating button에 진행 중인 draft 수가 표시된다', async ({ browser }) => {
+ const ctx = await browser.newContext({ storageState: path.join(__dirname, '.auth/guest.json') });
+ const page = await ctx.newPage();
+
+ try {
+ await page.goto(`/group/${groupId}`);
+ await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
+
+ // floating button에 draft 개수 badge가 표시되는지 확인
+ const badge = page.locator('span').filter({ hasText: /^[1-9]\d*$/ }).first();
+ await expect(badge).toBeVisible({ timeout: 10000 });
+ } finally {
+ await ctx.close();
+ }
+ });
+
+ test('한 멤버가 블록 내용을 수정하면 다른 멤버 화면에 실시간으로 반영된다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB, textInputA } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ try {
+ // 멤버 A가 텍스트 블록에 실제 타이핑으로 입력 (BLOCK_VALUE_STREAM 트리거)
+ await textInputA.click();
+ await textInputA.pressSequentially(DRAFT_TEXT, { delay: 50 });
+
+ // 멤버 B 화면에 실시간으로 반영 확인 (BLOCK_VALUE_STREAM)
+ await expect(pageB.getByText(DRAFT_TEXT)).toBeVisible({ timeout: 10000 });
+
+ // A가 blur → PATCH_APPLY → PATCH_COMMITTED, B에서 확정된 값으로 표시
+ await textInputA.press('Tab');
+ await expect(pageB.getByText(DRAFT_TEXT)).toBeVisible({ timeout: 5000 });
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+
+ test('한 멤버가 블록을 편집 중이면 다른 멤버 화면에 잠금 아이콘이 표시된다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB, textInputA } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ try {
+ // 멤버 A가 블록 클릭 → LOCK_ACQUIRE → LOCK_GRANTED → LOCK_CHANGED 브로드캐스트
+ await textInputA.click();
+
+ // 멤버 B 화면에 락 아이콘(animate-pulse 원형 아바타)이 나타나는지 확인
+ const lockIndicator = pageB.locator('[class*="animate-pulse"]');
+ await expect(lockIndicator).toBeVisible({ timeout: 10000 });
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+
+ test('편집 중인 멤버가 블록을 떠나면 잠금이 해제되어 다른 멤버가 편집할 수 있다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB, textInputA, textInputB } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ try {
+ // A가 블록 클릭 → 락 획득, B에서 lock indicator 확인
+ await textInputA.click();
+ const lockIndicator = pageB.locator('[class*="animate-pulse"]');
+ await expect(lockIndicator).toBeVisible({ timeout: 10000 });
+
+ // A가 blur → LOCK_RELEASE, B에서 lock indicator 사라짐
+ await textInputA.press('Tab');
+ await expect(lockIndicator).not.toBeVisible({ timeout: 8000 });
+
+ // B가 이제 같은 블록을 편집할 수 있음 (LOCK_GRANTED)
+ await textInputB.click();
+ await textInputB.pressSequentially(LOCK_RELEASE_TEXT, { delay: 50 });
+
+ // A 화면에 B가 입력한 내용이 반영됨
+ await expect(pageA.getByText(LOCK_RELEASE_TEXT)).toBeVisible({ timeout: 10000 });
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+
+ test('멤버가 draft 페이지를 떠나면 다른 멤버의 편집 중 인원 수가 감소한다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ try {
+ // B가 draft 페이지를 떠남 → LEAVE_DRAFT 이벤트 발생
+ await pageB.goto('/');
+
+ // A 화면에서 인원 수가 1명으로 줄어듦 (PRESENCE_LEFT 수신)
+ await expect(pageA.getByText(/1명.*편집 중/)).toBeVisible({ timeout: 10000 });
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+
+ test('한 멤버가 제목을 입력하면 다른 멤버 화면에 실시간으로 반영된다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ try {
+ const titleInputA = pageA.getByPlaceholder('제목을 입력하세요');
+ const titleInputB = pageB.getByPlaceholder('제목을 입력하세요');
+ await expect(titleInputA).toBeVisible({ timeout: 10000 });
+ await expect(titleInputB).toBeVisible({ timeout: 10000 });
+
+ // A가 제목 입력 (BLOCK_SET_TITLE 패치)
+ await titleInputA.click();
+ await titleInputA.pressSequentially(TITLE_TEXT, { delay: 50 });
+
+ // B 화면에 제목 반영 확인 (input value는 toHaveValue로 검증)
+ await expect(titleInputB).toHaveValue(TITLE_TEXT, { timeout: 10000 });
+ } finally {
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+
+ test('한 멤버가 저장하면 모든 멤버 화면에 저장 로더가 표시되고, 저장 완료 후 기록 상세에서 편집 참여자가 역할과 함께 표시된다', async ({ browser }) => {
+ const { ctxA, ctxB, pageA, pageB, textInputB } = await openDraftAsTwoMembers(
+ browser, groupId, inviteCode, draftId,
+ );
+ let postId: string | null = null;
+ try {
+ // B가 블록을 편집해 contributor로 등록 (PATCH_COMMITTED)
+ await textInputB.click();
+ await textInputB.pressSequentially('B참여자등록텍스트', { delay: 50 });
+ await textInputB.press('Tab');
+
+ // B의 패치가 서버에 커밋되어 A 화면에 반영될 때까지 대기
+ // → 이 시점에 draftStateService.getTouchedBy()에 B의 userId가 등록됨
+ await expect(pageA.getByText('B참여자등록텍스트')).toBeVisible({ timeout: 10000 });
+
+ // A가 저장 버튼 클릭 → DRAFT_PUBLISH_STARTED 브로드캐스트
+ const saveBtn = pageA.getByRole('button', { name: '저장' });
+ await expect(saveBtn).toBeVisible({ timeout: 10000 });
+
+ // 오버레이 리스너를 먼저 등록한 후 저장 — 빠른 저장으로 오버레이를 놓치지 않도록
+ const overlayAppeared = pageB.locator('[data-auth-loading]').waitFor({ state: 'visible', timeout: 8000 }).catch(() => {});
+ await saveBtn.click();
+ await overlayAppeared; // DRAFT_PUBLISH_STARTED → isPublishing = true
+
+ // 저장 완료 후 A와 B 모두 기록 상세 페이지로 이동 (DRAFT_PUBLISHED)
+ await expect(pageA).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 });
+ await expect(pageB).toHaveURL(/\/record\/[a-z0-9-]+/, { timeout: 20000 });
+ expect(pageA.url()).toBe(pageB.url());
+
+ postId = pageA.url().split('/record/')[1]?.split('?')[0] ?? null;
+
+ // 기록 상세 페이지의 작성자 섹션이 렌더링됐는지 확인
+ const contributorsHeading = pageA.getByRole('heading', { name: '작성자' });
+ await expect(contributorsHeading).toBeVisible({ timeout: 8000 });
+
+ // 역할 배지(span)로 기여자 등록 여부 검증 — h2 헤딩 "작성자"와 구분되어 span만 매칭됨
+ // 서버 정책: 모든 기여자에게 AUTHOR 역할 할당 → UI에서 "작성자" 배지로 표시됨
+ const roleBadges = pageA.locator('span').filter({ hasText: /^(작성자|편집자)$/ });
+ await expect(roleBadges.first()).toBeVisible({ timeout: 8000 });
+ // 저장자(A)와 편집 참여자(B) 모두 기여자로 표시됨
+ await expect(roleBadges.nth(1)).toBeVisible({ timeout: 5000 });
+ } finally {
+ if (postId) await deleteTestRecord(pageA, postId).catch(() => {});
+ await ctxA.close();
+ await ctxB.close();
+ }
+ });
+});
diff --git a/frontend/e2e/group-invite.spec.ts b/frontend/e2e/group-invite.spec.ts
new file mode 100644
index 000000000..e10d2ac6e
--- /dev/null
+++ b/frontend/e2e/group-invite.spec.ts
@@ -0,0 +1,75 @@
+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 }) => {
+ // 초대 페이지는 커버 이미지 로드로 'load' 이벤트가 느릴 수 있어 domcontentloaded 사용
+ await page.goto(`/invite?inviteCode=${inviteCode}`, { waitUntil: 'domcontentloaded' });
+ 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}`, { waitUntil: 'domcontentloaded' });
+ // API 응답 및 React hydration 완료 대기 (이 텍스트는 useQuery 응답 후에만 렌더링됨)
+ await expect(anonPage.getByText('회원님을 그룹에 초대했습니다.')).toBeVisible({ timeout: 10000 });
+ 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('초대 코드 없이 접근하면 유효하지 않은 초대 링크 메시지가 표시된다', async ({ page }) => {
+ await page.goto('/invite');
+ await expect(page.getByText('유효하지 않은 초대 링크')).toBeVisible({ timeout: 8000 });
+ });
+});
diff --git a/frontend/e2e/group-leave.spec.ts b/frontend/e2e/group-leave.spec.ts
new file mode 100644
index 000000000..aeae9dee2
--- /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 000000000..8fa24e3bc
--- /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는 history.replaceState라 waitForURL 대신 waitForFunction으로 URL 직접 polling)
+ await page.getByRole('button', { name: '보관함' }).click();
+ await page.waitForFunction(() => window.location.search.includes('tab=archive'), { timeout: 8000 });
+
+ // 피드 탭 복귀
+ await page.getByRole('button', { name: '피드' }).click();
+ await page.waitForFunction(() => !window.location.search.includes('tab=archive'), { timeout: 8000 });
+ });
+
+ 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 000000000..2311aad79
--- /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/home.spec.ts b/frontend/e2e/home.spec.ts
new file mode 100644
index 000000000..d994c91af
--- /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('/');
+
+ //