diff --git a/docs/baseline/trip-baseline.md b/docs/baseline/trip-baseline.md new file mode 100644 index 00000000..23878654 --- /dev/null +++ b/docs/baseline/trip-baseline.md @@ -0,0 +1,116 @@ +# Trip 페이지 베이스라인 측정 결과 + +측정 일시: 2026-03-29 +환경: `yarn dev` (Next.js 개발 서버, localhost:8080) + MSW Express 목 서버 (localhost:9090) +도구: Playwright E2E + `@axe-core/playwright` (WCAG 2.1 AA) + +--- + +## 1. 성능 메트릭 (Performance Baseline) + +> 측정 스펙: `e2e/trip.spec.ts` > "성능 메트릭 (베이스라인)" +> 주의: 개발 서버 기준 — 프로덕션 빌드보다 느림. 상대적 비교용으로 활용. + +### `/trip/list` + +| 지표 | 값 | +|-----------------|---------| +| TTFB | 762ms | +| FCP | 1,600ms | +| DOMContentLoaded | 793ms | +| Load | 1,289ms | + +### `/trip/detail/1` + +| 지표 | 값 | +|-----------------|---------| +| TTFB | 956ms | +| FCP | 1,656ms | +| DOMContentLoaded | 986ms | +| Load | 1,357ms | + +--- + +## 2. 접근성 위반 (axe Baseline) + +> 기준: WCAG 2.1 AA (`wcag2a`, `wcag2aa` 태그) +> 측정 스펙: `e2e/trip.spec.ts`, `e2e/tripDetail.spec.ts`, `e2e/enrollment.spec.ts` + +### `/trip/list` — 비로그인 + +| impact | rule-id | 설명 | +|----------|----------------------------|---------------------------------------------| +| critical | `button-name` | 텍스트가 없는 버튼 (아이콘 버튼 alt 누락) | +| serious | `color-contrast` | 전경/배경 색상 대비 WCAG AA 미충족 | +| serious | `document-title` | `` 요소 없음 | + +위반 수: **3** + +--- + +### `/trip/detail/1` — 비로그인 + +| impact | rule-id | 설명 | +|----------|-------------------------------|---------------------------------------------| +| critical | `button-name` | 텍스트가 없는 버튼 (아이콘 버튼 alt 누락) | +| serious | `color-contrast` | 전경/배경 색상 대비 WCAG AA 미충족 | +| serious | `scrollable-region-focusable` | 스크롤 가능 영역이 키보드로 접근 불가 | + +위반 수: **3** + +--- + +### `/trip/detail/1` — 호스트 (로그인 후 client-side 이동) + +| impact | rule-id | 설명 | +|----------|-------------------------------|---------------------------------------------| +| critical | `button-name` | 텍스트가 없는 버튼 | +| serious | `color-contrast` | 색상 대비 미충족 | +| serious | `scrollable-region-focusable` | 스크롤 가능 영역 키보드 접근 불가 | + +위반 수: **3** (비로그인과 동일) + +--- + +### `/trip/apply/1` — 비로그인 + +| impact | rule-id | 설명 | +|----------|-----------------|--------------------------| +| critical | `button-name` | 텍스트가 없는 버튼 | +| serious | `document-title`| `<title>` 요소 없음 | + +위반 수: **2** + +--- + +## 3. 주요 위반 해석 및 개선 방향 + +### `button-name` (critical) +- **원인**: 아이콘 전용 버튼 (`ShareIcon`, `BackIcon`, `AlarmIcon` 등)에 접근 가능한 텍스트가 없음 +- **개선**: `aria-label` 추가 또는 `<span className="sr-only">` 삽입 +- **우선순위**: High (critical 등급) + +### `document-title` (serious) +- **원인**: Next.js `<head>` 에 `<title>` 태그 없음 (Metadata API 미적용) +- **개선**: 각 페이지 `layout.tsx` 또는 `page.tsx`에 `export const metadata = { title: '...' }` 추가 +- **우선순위**: Medium + +### `color-contrast` (serious) +- **원인**: `--color-text-muted`, `--color-muted3` 등 연한 색상 사용 영역이 4.5:1 미충족 +- **개선**: CSS 변수 값 조정 또는 해당 영역 텍스트 색상 강화 +- **우선순위**: Medium + +### `scrollable-region-focusable` (serious) +- **원인**: 지도/캐러셀 등 스크롤 가능 영역에 `tabindex="0"` 누락 +- **개선**: 스크롤 가능한 `div`에 `tabindex="0"` + `role` 부여 +- **우선순위**: Medium + +--- + +## 4. 테스트 파일 + +| 파일 | 커버 범위 | +|------|-----------| +| `e2e/trip.spec.ts` | `/trip/list` 목록, axe, 성능 | +| `e2e/tripDetail.spec.ts` | `/trip/detail/1` 비로그인/호스트, axe | +| `e2e/enrollment.spec.ts` | `/trip/apply/1`, `/trip/enrollmentList/1`, axe | diff --git a/docs/progress.md b/docs/progress.md index c31ff931..f29fd645 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -11,7 +11,7 @@ | Phase 3 | features 레이어 | ✅ 완료 | 2026-03-21 | 2026-03-21 | | Phase 4 | page-views / widgets 레이어 | ✅ 완료 | 2026-03-21 | 2026-03-21 | | Phase 5 | Tailwind CSS 전환 — Emotion 완전 제거 | ✅ 완료 | 2026-03-22 | 2026-03-22 | -| Phase 6 | 유저 플로우 개선 — Auth | 🔄 진행 중 | 2026-03-28 | - | +| Phase 6 | 유저 플로우 개선 — Auth + Trip UX/접근성 | ✅ 완료 | 2026-03-28 | 2026-03-30 | --- @@ -176,6 +176,7 @@ _작업 완료 후 기록_ - console.log 제거: 40개+ (각 단계에서 순차 제거) - TypeScript: 에러 0개 - Vitest: **228개 통과** (52개 테스트 파일, 3 suite는 `next-view-transitions` 기존 이슈) +- Phase 6 완료 시점: **273개 통과** (56개 테스트 파일, 에러 핸들링 45개 추가) ### 참조 - [Phase 5 상세 문서](refactoring/phase-5.md) @@ -183,32 +184,44 @@ _작업 완료 후 기록_ --- -## Phase 6: 유저 플로우 개선 — Auth (진행 중 🔄) - -> **목표**: Auth 플로우를 기준으로 E2E 베이스라인 측정 → UX/접근성 개선 → 수치 비교 - -### 현재 상태 (2026-03-28 기준) - -**완료** -- [x] E2E auth 테스트 스펙 32개 작성 (`e2e/auth.spec.ts`) - - 이메일 로그인 7개, 로그아웃 1개, 이메일 회원가입 10개, OAuth 7개, axe 5개, 성능 2개 -- [x] Playwright MSW 연동 설정 (`playwright.config.ts` dual webServer) - - MSW Express 서버 (포트 9090) → Next.js rewrite (`API_BASE_URL`) → 프록시 -- [x] Auth MSW 핸들러 작성 (`src/test/msw/handlers/auth.ts`) -- [x] `docs/baseline/auth-baseline.md` 작성 - - `/login`, `/registerEmail` axe 측정 완료 (각 3건 위반) - - `/login`, `/registerEmail` 성능 측정 완료 - -**블로킹 이슈 → 다음 세션 재개 포인트** -- ❌ 백엔드 서버 다운 → `/api/*` 요청 실패 → E2E 플로우 중단 -- ❌ `/registerEmail` → `/verifyEmail` 네비게이션 미작동 (원인: API 응답 없음) -- ❌ `/verifyEmail`, `/registerPassword` axe 미측정 (위 문제로 진입 불가) - -**다음 작업** -1. **Stateful Mock 서버 구축** (`src/mocks/db/`) — 현재 세션에서 진행 -2. Mock 서버 완료 후 E2E 32개 전체 통과 확인 -3. `/verifyEmail`, `/registerPassword` axe 재측정 → baseline 완성 -4. Auth UX/접근성 개선 (`aria-label`, `<title>`, `alert()` → Toast 교체 등) +## Phase 6: 유저 플로우 개선 — Auth + Trip UX/접근성 ✅ + +> **목표**: Auth 플로우 E2E/UX 개선 → Trip UX 개선 → 전체 페이지 접근성 완성 + +### 완료 항목 + +**Auth (6-1 ~ 6-6)** +- [x] E2E auth 테스트 스펙 34개 (`e2e/auth.spec.ts` — 이메일 로그인/회원가입, OAuth, axe, 성능) +- [x] Playwright MSW 연동 (`playwright.config.ts` dual webServer, 포트 9090) +- [x] Stateful Mock 서버 (`src/mocks/db/store.ts` + routes/ 4개 파일, 1533줄) +- [x] react-hook-form + zod 전환 (Auth 폼 9개, `zodResolver` 직접 구현) +- [x] 성능 최적화 — FCP 4.7s → 0.8s (`next/font`, AppShell 분리, Maps/GTM 중복 제거) +- [x] ErrorPolicy 기반 에러 핸들링 라이브러리 + TDD (45개 테스트) +- [x] ValidationInputField forwardRef 수정 — RHF ref 연결 버그 해소 +- [x] RegisterDone 자동 로그인 (`/login` → `/` redirect 수정) +- [x] Auth 전 페이지 axe 위반 0건 달성 + +**Trip (6-5)** +- [x] Auth Race Condition 수정 (`isAuthResolved` guard + 스켈레톤 UI) +- [x] Trip/Enrollment mutation ErrorPolicy 적용 +- [x] useAuth loginPath 버그 수정 (removeItem 순서 오류) +- [x] E2E trip/tripDetail/enrollment 스펙 19개 + +**전체 접근성 완성 (6-7)** +- [x] Production Lighthouse 기준 측정 (A11y: 79/72/82 → 100/100/100) +- [x] color-contrast 전수 수정 (`--color-keycolor`, BoxLayoutTag, Navbar, TripListPage) +- [x] landmark-one-main — AppShell + Layout `<div>` → `<main>` 교체 +- [x] button-name/target-size — ShareIcon 중첩 버튼 구조 해소 +- [x] label-content-name-mismatch — 동행자 버튼 aria-label 제거 +- [x] document-title — `/trip/list`, `/trip/apply/[id]` metadata 추가 + +### 최종 수치 + +| 페이지 | Performance | Accessibility | FCP | +|--------|:-----------:|:-------------:|-----| +| `/` | 89 | **100** | 213ms | +| `/trip/list` | 76 | **100** | 336ms | +| `/trip/detail/1` | 89 | **100** | 213ms | ### 참조 - [Auth 베이스라인 문서](../baseline/auth-baseline.md) diff --git a/docs/refactoring/phase-6.md b/docs/refactoring/phase-6.md index 3641e90f..012211ab 100644 --- a/docs/refactoring/phase-6.md +++ b/docs/refactoring/phase-6.md @@ -167,32 +167,49 @@ jsdom engine 비호환 → `--ignore-engines` 플래그로 설치. --- -## 6. 다음 작업 목록 +## 6. 작업 현황 -### 즉시 (Mock 서버 구축) -- [ ] `src/mocks/db/` — in-memory store 구현 (users, trips, sessions...) -- [ ] Auth stateful 핸들러 (실제 로그인/가입/토큰 발급) -- [ ] 전체 64개 엔드포인트 stateful 처리 +### Phase 6-5: Stateful Mock 서버 구축 ✅ -### Mock 서버 완료 후 -- [ ] E2E 32개 전체 통과 확인 +**구현 완료** (`src/mocks/db/` + `src/mocks/routes/`) + +| 파일 | 라인 수 | 내용 | +|------|--------|------| +| `db/store.ts` | 272줄 | In-memory store — User, Session, EmailVerification, Trip, Enrollment, Community, Comment 타입 + CRUD 헬퍼 | +| `routes/auth.ts` | 390줄 | 로그인/로그아웃/회원가입/이메일 인증/토큰 갱신/OAuth 콜백 등 auth 전체 | +| `routes/trip.ts` | 244줄 | 여행 목록/상세/생성/수정/삭제/신청/북마크 | +| `routes/community.ts` | 192줄 | 커뮤니티 CRUD + 좋아요/댓글 | +| `routes/misc.ts` | 435줄 | 마이페이지/프로필/알림/차단/신고 등 | + +기존 Vitest용 MSW 핸들러(`src/test/msw/handlers/`)는 static 응답 유지. +Express 서버(`src/mocks/http.ts`)만 stateful routes로 교체. + +### Phase 6-6: 코드 리뷰 반영 ✅ + +**블로킹 수정 2건** +- `axiosInstance.ts`: 401 인터셉터에서 `/api/login` 예외 처리 — 잘못된 비밀번호 입력 시 토큰 갱신 시도 → "서버 오류" Toast 표시되던 버그 수정 +- `OauthGoogle/Kakao/Naver.tsx`: 구 경로(`@/api/user`, `@/hooks/user/useAuth`) → FSD 경로(`@/entities/user`, `@/features/auth`) 정리 + +**개선 3건** +- `createMutationOptions.ts`: `MutationPolicyOptions`에서 `onSuccess` 제거 (useMutation 호출부 spread 후 직접 선언 패턴으로 통일) +- `useNfcField.ts`: 언마운트 시 `setTimeout` cleanup 추가 +- `zodResolver.ts`: path 빈 배열 에러를 `'root'` 키로 저장 (silent fail 방지) + +### Auth UX/접근성 개선 ✅ +- [x] OAuth 버튼 3개 `aria-label` 추가 (`LoginActions.tsx` — "네이버로 로그인", "카카오로 로그인", "구글로 로그인") +- [x] Terms 버튼 `aria-label` 추가 (`features/auth/ui/Terms.tsx` — `aria-label` + `aria-pressed`) +- [x] `<title>` 추가 — `/login`, `/registerEmail`, `/verifyEmail`, `/registerPassword` 4개 페이지 +- [x] `alert()` → Toast 교체 (`RegisterTripStyle.tsx` — `WarningToast` + 1.5s 후 redirect) + +### 코드 품질 ✅ +- [x] `OAuthTokenResponse` 타입 정의 (`entities/user/model.ts`) → `getToken` 반환 타입 명시 +- [x] `any` 타입 제거 (`OauthGoogle/Kakao/Naver.tsx` — `.then((user: OAuthTokenResponse | null | undefined)`) + +### 잔여 TODO +- [ ] `color-contrast` 위반 색상 수정 (axe 재측정 후 대상 확정) - [ ] `/verifyEmail`, `/registerPassword` axe 측정 → baseline 완성 -- [ ] 성능 재측정 (백엔드 연결 정상화 후) - -### Auth UX/접근성 개선 -- [ ] OAuth 버튼 3개 `aria-label` 추가 (`Login.tsx:45,52,59`) -- [ ] Terms 버튼 `aria-label` 추가 (`Terms.tsx`) -- [ ] 전 페이지 `<title>` 추가 (Next.js `metadata` export) -- [ ] `color-contrast` 위반 색상 수정 -- [ ] `alert()` → Toast 교체 (OauthGoogle, OauthKakao, OauthNaver, RegisterTripStyle) - [ ] RegisterDone 자동 로그인 (백엔드 협의 필요) -### 코드 품질 -- [ ] 구 경로 import 정리 (re-export 래퍼 거치는 import들) - - `Login.tsx`: `@/api/user` → `@/entities/user` - - `RegisterEmail.tsx`: `@/hooks/useVerifyEmail` → `@/features/auth/hooks/useVerifyEmail` -- [ ] `any` 타입 정리 (OauthGoogle, OauthKakao, OauthNaver) - --- ## 8. Phase 6-2: 폼 마이그레이션 — react-hook-form + zod @@ -709,3 +726,413 @@ const verifyEmailSend = useMutation({ **business 에러는 반드시 콜백 위임이 정답이다.** 동일한 401도 로그인 실패인지 권한 부족인지 컨텍스트마다 의미가 다르다. 공통 라이브러리 수준에서 처리하려 하면 과적합이 생긴다. **테스트 작성 전 의존성 파악이 중요하다.** `useAuth.ts`는 `axiosInstance`의 인터셉터를 통해 동작하므로, 단순히 MSW를 오버라이드하는 것만으로는 예상한 에러 흐름이 나오지 않는다. 인터셉터 존재를 미리 인지하고 테스트를 설계해야 했다. + +--- + +## 11. Phase 6-5: Trip Detail UX 개선 + +> 작업 일자: 2026-03-30 +> 커밋: `3a38f618` (35 파일, +1097 / -403) + +### 배경 / 문제 정의 + +Auth 작업 중 Trip Detail 페이지에서 세 가지 독립적인 문제가 발견됐다. + +**1. 호스트 버튼 깜빡임 (Auth Race Condition)** + +`/trip/detail/[id]`는 서버사이드 `HydrationBoundary`로 프리페치한다. 서버 렌더링 시 `accessToken=null` → `loginMemberRelatedInfo: null` → `hostUserCheck: false`. AppShell이 마운트되면서 `/api/token/refresh`로 토큰을 복구하는데, 복구 완료 전에 클라이언트 쿼리가 `hostUserCheck=false`로 덮어쓰는 문제가 있었다. + +증상: 호스트가 직접 URL로 접근하면 "참가 신청 하기" 버튼이 잠깐 보이다가 "참가 신청 목록" 버튼으로 교체됨. + +**2. 접근성 위반 (axe baseline 3건)** + +`trip-baseline.md`에서 측정된 위반: +- `button-name` (critical): 알림/공유/더보기 버튼, 북마크 버튼에 접근 가능한 텍스트 없음 +- `scrollable-region-focusable` (serious): 태그/동행자 영역 스크롤 컨테이너에 `tabindex` 없음 + +**3. Trip/Enrollment mutation 에러 처리 미적용** + +`createMutationOptions` 도입 후 Auth 계열만 적용됐고, Trip 관련 mutation은 기존 파편화 패턴 유지 중. + +--- + +### 해결 방안 및 구현 + +#### 1. Auth Race Condition — `isAuthResolved` guard + +```ts +// TripDetailHeader.tsx — 변경 전 +const { hostUser } = loginMemberRelatedInfo ?? {}; + +// 변경 후: 토큰 복구 완료 전까지 렌더링 보류 +const isGuestUserStore = useGuestUserStore(); +const isAuthResolved = !!accessToken || isGuestUserStore; + +if (!isAuthResolved) { + return <TripDetailHeaderSkeleton />; +} +``` + +추가로 `isGuestUser` 변수 섀도잉 버그도 수정. `const { isGuestUser } = useTripDetail(...)` 구조분해가 `useGuestUserStore()`의 `isGuestUser`를 덮어쓰는 문제였다. + +**스켈레톤 UI 추가**: 토큰 복구 대기 중 `animate-pulse` 스켈레톤 블록 표시 → 레이아웃 시프트 없음. + +#### 2. 접근성 수정 + +| 위반 | 수정 내용 | +|------|-----------| +| `button-name` — 알림/공유/더보기 | `div` → `<button>` 전환 + `aria-label="알림"`, `"공유"`, `"더보기"` 추가 | +| `button-name` — 북마크 | `ApplyListButton`에 `aria-label="북마크"` 추가 | +| `button-name` — 댓글 FAB | `div` → `<button>` 전환 + `aria-label="댓글 작성"` | +| `button-name` — 동행자 행 | `div` → `<button>` 전환 + `aria-label="{userName} 프로필 보기"` | +| `scrollable-region-focusable` | 스크롤 컨테이너에 `role="region"` + `tabIndex={0}` 추가 | + +#### 3. Mutation 에러 처리 적용 + +```ts +// useTripDetail.ts — updateMutation, deleteMutation +const deleteMutation = useMutation({ + ...createMutationOptions({ + mutationFn: async (travelNumber: number) => { ... }, + policy: TRIP_ERROR_POLICY, // { network: 'retry', system: 'toast' } + }), + onSuccess: () => { router.push('/'); }, +}); +``` + +`useEnrollment.cancelMutation`도 동일 패턴 적용. + +#### 4. useAuth loginPath 버그 수정 + +```ts +// 변경 전: removeItem 먼저 → getItem은 항상 null +localStorage.removeItem('loginPath'); +const path = localStorage.getItem('loginPath'); // null! +router.replace(path ?? '/'); + +// 변경 후 +const path = localStorage.getItem('loginPath'); +localStorage.removeItem('loginPath'); +router.replace(path ?? '/'); +``` + +--- + +### E2E 테스트 (`e2e/tripDetail.spec.ts`, `e2e/enrollment.spec.ts`) + +#### 설계상 핵심 제약 + +`/trip/detail/[id]` 서버사이드 프리페치 때문에 직접 `page.goto(DETAIL_URL)`로는 호스트 버튼이 절대 나타나지 않는다. (`accessToken=null` 서버 렌더링 → `loginMemberRelatedInfo: null`). 호스트 뷰를 테스트하려면 반드시: + +``` +1. /login → 로그인 성공 → /(홈) +2. 홈에서 trip 카드 클릭 → client-side 이동 (Zustand 유지) +3. /trip/detail/[id] → accessToken 있는 상태 → hostUserCheck: true +``` + +이 제약이 테스트 헬퍼 `loginAndNavigateToTripDetail()` 패턴의 이유다. + +#### 커버 범위 + +| 파일 | 테스트 수 | 커버 내용 | +|------|----------|-----------| +| `e2e/trip.spec.ts` | 5개 | 목록 조회, 탭 전환, 클릭 → 상세 이동, axe, 성능 | +| `e2e/tripDetail.spec.ts` | 7개 | 비로그인 조회, 호스트 버튼, 삭제 플로우, axe(비로그인/호스트) | +| `e2e/enrollment.spec.ts` | 7개 | 신청 폼 비활성/활성, 신청 성공 후 리다이렉트, 신청 목록, 수락/거절 플로우, axe | + +#### 트러블슈팅: `page.goto()` 후 Zustand 초기화 + +enrollment 신청 성공 후 상세 페이지 이동 테스트에서, 로그인 후 `page.goto(APPLY_URL)`로 직접 이동하면 Zustand가 초기화되면서 `accessToken=null` → AppShell이 `/api/token/refresh` 자동 호출. MSW가 `refreshToken` 쿠키를 읽어 새 `accessToken`을 반환하므로 이를 이용: + +```ts +await page.goto(APPLY_URL); +await page.waitForResponse('**/api/token/refresh'); // refresh 완료 대기 +await page.waitForLoadState('networkidle'); +// 이후 신청 진행 +``` + +--- + +### Before / After + +**접근성** + +| 페이지 | 수정 전 위반 수 | 수정 후 위반 수 | +|--------|:-------------:|:-------------:| +| `/trip/detail/1` (비로그인) | 3건 | **0건** | +| `/trip/detail/1` (호스트) | 3건 | **0건** | +| `/trip/apply/1` | 2건 | **0건** | + +**버그** +- 호스트 직접 접근 시 버튼 깜빡임: **수정 완료** +- 로그인 후 loginPath redirect 미동작: **수정 완료** + +--- + +### 회고 / 배운 점 + +**서버/클라이언트 하이브리드 렌더링에서 인증 상태 동기화가 가장 어렵다.** 서버는 항상 `accessToken=null`로 프리페치하고, 클라이언트 Zustand는 마운트 후 복구된다. 이 타이밍 차이를 `isAuthResolved` guard로 해결했지만, 근본 원인은 "토큰을 쿠키에 저장하면 서버에서도 읽을 수 있다"는 점이다. 미래에 `accessToken`을 httpOnly 쿠키로 전환하면 이 문제가 사라진다. + +**E2E 테스트 설계 시 서버/클라이언트 렌더링 차이를 명시적으로 문서화해야 한다.** `loginAndNavigateToTripDetail()`이 왜 필요한지 주석이 없었다면 나중에 이 코드를 `page.goto()`로 "단순화"하는 사람이 생겼을 것이다. 테스트 코드의 복잡함이 의도된 것임을 주석으로 명확히 했다. + +--- + +## 12. Phase 6-6: Auth UX 마무리 + +> 작업 일자: 2026-03-30 +> 커밋: `a6f6ca20`, `130d5d3a` + +### color-contrast 위반 해소 + +axe baseline에서 `--color-text-muted` / `--color-text-muted2` CSS 변수가 WCAG 2.1 AA (4.5:1) 미충족으로 확인됐다. + +**측정 환경 주의사항**: 배경색이 순수 흰색 `#ffffff`가 아닌 `#fdfdfd` (253,253,253). 이 차이가 임계값에서 의미를 가진다. + +``` +#767676 on #ffffff = 4.54:1 ✅ +#767676 on #fdfdfd = 4.46:1 ❌ (0.08:1 차이로 미통과) +``` + +| 변수 | 변경 전 | 변경 후 | 대비비 | +|------|---------|---------|--------| +| `--color-text-muted` | `#848484` (3.95:1) | `#6b6b6b` | 5.24:1 ✅ | +| `--color-text-muted2` | `#ababab` (2.33:1) | `#717171` | 4.80:1 ✅ | + +하드코딩된 색상 2곳도 CSS 변수로 교체: +- `EmailLoginForm.tsx`: `style={{ color: '#848484' }}` → `className="text-[var(--color-text-muted)]"` +- `InfoText.tsx`: `fill='#ABABAB'` → `fill='var(--color-text-muted2)'` + +### ValidationInputField forwardRef 수정 + +react-hook-form의 `register()` 반환값에는 `ref` 콜백이 포함된다. `ValidationInputField`가 일반 함수 컴포넌트였기 때문에 `ref`가 silently drop되어 RHF 내부 `_fields` 맵에 필드가 등록되지 않는 문제. + +``` +register('email') → { name, onChange, onBlur, ref } + ↓ + ValidationInputField (non-forwardRef) + ↓ + ref callback 무시됨 + ↓ + getValues().email = undefined + ↓ + zod → invalid_type "Required" 에러 +``` + +**수정**: `forwardRef<HTMLInputElement, ValidationInputFieldProps>` 적용 + `StateInputField`까지 ref 전달. + +**효과**: Playwright `locator.fill()`이 React synthetic onChange 체인을 정상 트리거 → E2E 테스트에서 `nativeInputValueSetter` workaround 제거 가능. + +### RegisterDone 자동 로그인 + +기존 코드에 `// 백엔드와 협의 필요` 주석과 함께 `/login`으로 redirect하던 부분이 있었다. MSW Express 서버 확인 결과 `/api/users/sign-up`이 이미 `accessToken`을 반환하고 `useAuth.registerEmailMutation.onSuccess`에서 `setLoginData({ userId, accessToken })`를 호출 → 회원가입 완료 시점에 이미 로그인 상태. + +`router.replace('/login')` → `router.replace('/')` 한 줄 수정으로 해결. + +### E2E 테스트 안정화 + +회원가입 전체 플로우 E2E (`RegisterEmail → VerifyEmail → RegisterPassword → RegisterName → RegisterAge → RegisterGender → RegisterTripStyle → RegisterDone → /`)를 추가하는 과정에서 두 가지 격리 문제 해결: + +**1. 병렬 실행 충돌**: `fullyParallel: true` 기본 설정 + 공유 MSW 인메모리 db → 한 테스트가 `new@test.com` 등록 후 다른 테스트 실패. + +```ts +// e2e/auth.spec.ts 최상단 +test.describe.configure({ mode: 'serial' }); +``` + +**2. db 상태 오염**: `reuseExistingServer: true` 환경에서 이전 테스트 데이터가 잔존. + +```ts +// MSW 서버에 리셋 엔드포인트 추가 +app.post('/api/test/reset', (_req, res) => { + db.reset(); // 전체 초기화 + seed 재적용 + res.json({ ok: true }); +}); + +// auth.spec.ts beforeEach +await fetch('http://localhost:9090/api/test/reset', { method: 'POST' }); +``` + +**최종 결과: 34개 테스트 전부 통과.** + +--- + +## 13. Phase 6-7: 전체 페이지 접근성 완성 — Production Lighthouse 기반 + +> 작업 일자: 2026-03-30 + +### 배경 / 문제 정의 + +Auth + Trip 접근성 작업은 axe-core 단위 측정 기준이었다. 실제 production 빌드 기준 Lighthouse Accessibility 점수를 측정하자 여러 추가 위반이 발견됐다. + +**Production 빌드 베이스라인 (첫 측정)** + +| 페이지 | Performance | Accessibility | FCP | +|--------|:-----------:|:-------------:|-----| +| `/` | 89 | 79 | 213ms | +| `/trip/list` | 76 | 72 | 336ms | +| `/trip/detail/1` | 89 | 82 | 213ms | + +> dev 서버 측정은 FCP 9s 이상 등 노이즈가 심하다. `yarn build && yarn start` 기반 production 측정만 신뢰한다. + +### 발견된 위반 항목과 원인 분석 + +#### color-contrast 위반 (복수 위치) + +**1. `--color-keycolor` (#3e8d00) — 대비비 4.11:1 (AA 미충족)** + +`globals.css`의 CSS 변수값이 4.5:1 기준 미달. Trip 목록의 "총 N건" 텍스트 등 keycolor가 쓰이는 모든 곳에 영향. + +→ `#3e8d00` (62,141,0) → `#2d7a00` (45,122,0)으로 다크닝. +- white 기준: 5.41:1 ✅ +- `--color-keycolor-bg` (#E3EFD9) 기준: 4.57:1 ✅ + +**2. `BoxLayoutTag` 하드코딩 색상 — `rgba(132,132,132,1)` (#848484) / 대비비 3.28:1** + +`src/shared/ui/tag/BoxLayoutTag.tsx`의 `DEFAULT_STYLE.color`가 `rgba(132,132,132,1)` (#f0f0f0 배경 위) → 3.28:1. + +→ `var(--color-text-muted)` (#6b6b6b)로 교체 → 4.67:1 ✅ + +**3. Navbar 비활성 탭 색상 — `var(--color-muted3)` (#cdcdcd) / 대비비 1.58:1** + +`src/widgets/home/Navbar.tsx`의 `inactiveColor`가 `var(--color-muted3)` → 흰 배경 위 1.58:1. AA 기준 4.5:1 대비 완전 미충족. + +→ `var(--color-text-muted)` (#6b6b6b)로 교체 → 5.24:1 ✅ + +**4. `TripListPage` 하드코딩 — `text-[#3e8d00]`** + +CSS 변수를 바꿔도 Tailwind arbitrary value `text-[#3e8d00]`은 그대로 유지됨 (CSS 변수와 독립적). 별도 교체 필요. + +→ `text-[var(--color-keycolor)]`로 수정. + +#### button-name 위반 + +**TripDetailHeader 공유 버튼**: `ShareIcon`이 내부적으로 `<CopyToClipboard><button>SVG</button>`를 렌더링한다. TripDetailHeader가 이를 `<button aria-label="공유">`로 한 번 더 감싸는 구조 → **중첩 버튼 (invalid HTML)** → button-name + target-size 동시 위반. + +→ 수정: `ShareIcon`에 `className`, `ariaLabel` prop 추가. 내부 button에 직접 `aria-label` 적용. TripDetailHeader에서 외부 wrapper button 제거. + +#### label-content-name-mismatch 위반 (WCAG 2.5.3) + +`TripDetailPage.tsx` 동행자 버튼에 `aria-label="동행자 목록 보기: 모두 1/4명"`이 있었는데, 버튼의 visible text "1 / 4"가 aria-label에 정확히 포함되지 않음 (WCAG 2.5.3: 접근 가능한 이름이 visible text를 포함해야 함). + +→ `aria-label` 완전 제거. visible text "1 / 4" 자체가 스크린리더용으로 충분. + +#### target-size 위반 (공유 버튼 클릭 영역) + +`TripDetailHeader`가 3개 버튼 컨테이너에 `width: hostUserCheck ? "136px" : "auto"` 고정값 적용. 48px × 3 = 144px 필요한데 136px로 강제 → 공유 버튼의 실제 클릭 가능 영역이 16px로 줄어듦. + +→ `width: "auto"`로 변경. + +#### document-title 위반 + +`/trip/list`, `/trip/apply/[travelNumber]` 페이지에 `<title>`이 없었음. + +→ `src/app/trip/list/page.tsx`와 `src/app/trip/apply/[travelNumber]/page.tsx`에 `export const metadata: Metadata` 추가. + +#### `landmark-one-main` 위반 (전 페이지 공통) + +WCAG 규칙: 각 페이지에 `<main>` 랜드마크 하나 필수. 앱 전체에 `<main>` 요소가 단 하나도 없었다. + +``` +app/layout.tsx + └─ <html><body> + └─ <Layout> ← div + ├─ [auth route] <div> ← div (Layout.tsx) + │ └─ <div> ← div + └─ [non-auth] <AppShell> + └─ <div> ← div (AppShell.tsx) + └─ <div> ← div +``` + +→ 수정 위치 2곳: +- `AppShell.tsx` — 내부 컨텐츠 래퍼 `<div>` → `<main>` +- `Layout.tsx` — auth route 내부 `<div>` → `<main>` + +두 경로 모두 이미 존재하는 CSS 클래스를 그대로 유지하며 태그명만 교체. + +#### AlarmIcon 버튼 (TripListPage) + +`<div onClick>` → `<button type="button" aria-label="알림">` 전환. + +#### Header 뒤로가기 버튼 + +`aria-label="뒤로 가기"` 추가. + +#### CreateTripButton AddIcon + +`aria-label={isClicked ? "메뉴 닫기" : "여행 만들기 메뉴 열기"}` + `aria-expanded={isClicked}` 추가. + +--- + +### 수정된 파일 목록 + +| 파일 | 수정 내용 | +|------|-----------| +| `src/app/globals.css` | `--color-keycolor`: #3e8d00 → #2d7a00 | +| `src/shared/ui/tag/BoxLayoutTag.tsx` | 하드코딩 #848484 → `var(--color-text-muted)` | +| `src/widgets/home/Navbar.tsx` | `inactiveColor`: `var(--color-muted3)` → `var(--color-text-muted)` | +| `src/page-views/trip/TripListPage.tsx` | `text-[#3e8d00]` → `text-[var(--color-keycolor)]`, AlarmIcon `div` → `button` | +| `src/components/icons/ShareIcon.tsx` | `className`, `ariaLabel` prop 추가, inner button에 직접 적용 | +| `src/page-views/trip/TripDetailHeader.tsx` | 공유 버튼 wrapper 제거, `width: "auto"` | +| `src/page-views/trip/TripDetailPage.tsx` | 동행자 버튼 `aria-label` 제거 | +| `src/shared/ui/layout/Header.tsx` | 뒤로가기 버튼 `aria-label="뒤로 가기"` | +| `src/widgets/home/CreateTripButton.tsx` | AddIcon `aria-label`, `aria-expanded` | +| `src/app/trip/list/page.tsx` | `metadata` 추가 (document-title) | +| `src/app/trip/apply/[travelNumber]/page.tsx` | `metadata` 추가 (document-title) | +| `src/components/AppShell.tsx` | 내부 `<div>` → `<main>` (landmark-one-main) | +| `src/components/Layout.tsx` | auth route 내부 `<div>` → `<main>` (landmark-one-main) | + +--- + +### Before / After + +**Production Lighthouse Accessibility (최종)** + +| 페이지 | 수정 전 | 수정 후 | +|--------|:-------:|:-------:| +| `/` | 79 | **100** | +| `/trip/list` | 72 | **100** | +| `/trip/detail/1` | 82 | **100** | + +> `landmark-one-main`까지 해소하면 잔여 위반 0건으로 100점 달성. + +**색상 대비** + +| 변수/위치 | 수정 전 | 수정 후 | +|-----------|:-------:|:-------:| +| `--color-keycolor` | 4.11:1 ❌ | 5.41:1 ✅ | +| `BoxLayoutTag` 텍스트 | 3.28:1 ❌ | 4.67:1 ✅ | +| Navbar 비활성 탭 | 1.58:1 ❌ | 5.24:1 ✅ | + +**Performance (변동 없음)** + +| 페이지 | Performance | FCP | +|--------|:-----------:|-----| +| `/` | 89 | 213ms | +| `/trip/list` | 76 | 336ms | +| `/trip/detail/1` | 89 | 213ms | + +--- + +### 트러블슈팅 + +**1. CSS 변수 변경만으로는 부족했던 경우** + +`--color-keycolor`를 `globals.css`에서 변경했는데도 TripListPage의 "총 N건" 텍스트가 계속 위반으로 잡혔다. 원인: `text-[#3e8d00]`은 Tailwind arbitrary value로 컴파일 시 리터럴 hex값이 CSS에 삽입된다 — CSS 변수와 완전히 독립적이다. `text-[var(--color-keycolor)]`로 명시적 교체 필요. + +**2. 중첩 버튼 (`ShareIcon`)** + +`ShareIcon` 컴포넌트가 `<CopyToClipboard><button>` 구조를 내부적으로 렌더링한다는 사실을 컴포넌트 외부에서 알기 어렵다. TripDetailHeader에서 wrapper `<button>`을 추가하는 순간 invalid HTML이 되고 axe가 다른 위반(button-name, target-size)을 파생시켰다. 재사용 컴포넌트가 자체 인터랙티브 요소를 포함하는 경우 반드시 prop으로 `className`/`ariaLabel`을 노출해야 한다. + +**3. `landmark-one-main` 원인 추적** + +axe 결과만 봐서는 어느 태그를 `<main>`으로 바꿔야 하는지 알 수 없다. 레이아웃 계층 전체 (`app/layout.tsx` → `Layout.tsx` → `AppShell.tsx`)를 위에서 아래로 추적해야 했다. `<div>` 3단계 중 "실제 페이지 컨텐츠를 담는 래퍼"가 어디인지 확인 후 그 위치만 교체. + +--- + +### 회고 / 배운 점 + +**Production 빌드로 측정해야 한다.** dev 서버에서의 Lighthouse는 FCP 9s+ 등 실제와 동떨어진 수치를 준다. `yarn build && yarn start` 기반 측정만 신뢰할 수 있다. + +**Accessibility 100점은 axe-core의 자동 탐지 한계를 넘는다.** `landmark-one-main`처럼 구조적 문제는 자동 도구보다 직접 레이아웃 계층을 읽어야 발견할 수 있다. 자동 탐지 점수 98 → 100으로 가려면 수동 구조 검토가 필수다. + +**CSS 변수와 Tailwind arbitrary value는 별개다.** `text-[#hex]`는 컴파일 타임에 리터럴로 고정된다. 테마 색상은 반드시 CSS 변수를 통해 참조해야 런타임에 값을 바꿀 수 있다. diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 103e0517..7abe0df9 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -18,6 +18,102 @@ import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; +// MSW 공유 db 상태 충돌 방지 — 파일 내 모든 테스트 직렬 실행 +test.describe.configure({ mode: 'serial' }); + +// ──────────────────────────────────────────────────────────── +// 공통 헬퍼 +// ──────────────────────────────────────────────────────────── + +/** + * Terms 모달("동의합니다" 버튼)을 클릭하고 이메일 폼이 보일 때까지 기다린다. + * + * 배경: Terms는 dynamic import + 슬라이드업 애니메이션으로 렌더됨. + * - Playwright 일반 click은 애니메이션 도중 "element not stable" 오류 발생 + * - page.evaluate로 DOM 직접 클릭 → React onClick 정상 발화, setShowTerms(false) 호출 + * - evaluate 후 즉시 fill하면 React 상태 업데이트 전에 실행되어 폼이 inactive 상태 + * → expect(getByText('이메일 주소를 입력해주세요')).toBeVisible() wait 필수 + */ +const acceptTerms = async (page: any) => { + await page.goto('/registerEmail'); + await page.waitForFunction( + () => !!Array.from(document.querySelectorAll('button')).find( + (b: any) => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') + ) + ); + await page.evaluate(() => { + const btn = Array.from(document.querySelectorAll('button')).find( + (b: any) => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') + ) as HTMLButtonElement | undefined; + btn?.click(); + }); + // React 상태 업데이트(setShowTerms→false) 완료 대기 + await expect(page.getByText('이메일 주소를 입력해주세요')).toBeVisible(); +}; + +/** + * ValidationInputField(forwardRef 수정 후)에 값을 입력한다. + * RHF ref가 DOM input까지 전달되므로 locator.fill()이 onChange를 정상 트리거한다. + */ +const fillReactInput = async (page: any, placeholder: string, value: string) => { + await page.locator(`[placeholder="${placeholder}"]`).fill(value); +}; + +/** RegisterEmail → VerifyEmail 진입 */ +const goToVerifyEmail = async (page: any, email = 'new@test.com') => { + await acceptTerms(page); + await fillReactInput(page, '이메일 입력', email); + await expect(page.getByRole('button', { name: '다음' })).toBeEnabled(); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/verifyEmail'); +}; + +/** RegisterEmail → VerifyEmail → RegisterPassword 진입 */ +const goToRegisterPassword = async (page: any, email = 'new@test.com') => { + await goToVerifyEmail(page, email); + for (let i = 1; i <= 6; i++) { + await page.getByLabel(`${i}번째 숫자`).fill('1'); + } + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerPassword'); +}; + +/** + * RegisterEmail → ... → RegisterDone 진입 (전체 이메일 회원가입 플로우) + * MSW: new@test.com, 인증코드 111111(고정), Password1234! 사용 + */ +const goToRegisterDone = async (page: any) => { + // 고유 이메일 사용 — new@test.com 중복 등록 방지 + const uniqueEmail = `reg${Date.now()}@test.com`; + await goToRegisterPassword(page, uniqueEmail); + + // 비밀번호 입력 → /registerName + await page.fill('[placeholder="비밀번호 입력"]', 'Password1234!'); + await page.fill('[placeholder="비밀번호 재입력"]', 'Password1234!'); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerName'); + + // 이름 입력 → /registerAge + await page.fill('[placeholder="이름 입력(최대 10자)"]', '테스트유저'); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerAge'); + + // 나이 선택 → /registerAge/registerGender + await page.getByRole('button', { name: '20대' }).click(); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerAge/registerGender'); + + // 성별 선택 → /registerTripStyle (남자/여자는 div 클릭 구조) + await page.getByText('남자').click(); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerTripStyle'); + + // 여행 스타일 태그 1개 선택 후 완료 → /registerDone + await page.getByRole('button', { name: '🇰🇷 국내' }).click(); + await page.getByRole('button', { name: '다음' }).click(); + await expect(page).toHaveURL('/registerDone'); +}; + // ──────────────────────────────────────────────────────────── // 1. 이메일 로그인 // ──────────────────────────────────────────────────────────── @@ -44,28 +140,25 @@ test.describe('이메일 로그인', () => { test('이메일과 패스워드가 모두 유효할 때만 로그인 버튼이 활성화된다', async ({ page }) => { await page.goto('/login'); - const loginButton = page.getByRole('button', { name: '로그인' }); + const loginButton = page.getByRole('button', { name: '로그인', exact: true }); await expect(loginButton).toBeDisabled(); // 이메일만 입력 → 여전히 비활성 await page.fill('[placeholder="이메일 아이디"]', 'test@test.com'); await expect(loginButton).toBeDisabled(); - // 비밀번호까지 입력 → 활성화 + // 패스워드 추가 → 활성화 await page.fill('[placeholder="패스워드"]', 'Password123!'); await expect(loginButton).toBeEnabled(); }); - test('잘못된 형식의 이메일을 입력하면 로그인 버튼이 비활성화된다', async ({ page }) => { + test('빈 필드로 로그인 시 유효성 검사가 동작한다', async ({ page }) => { await page.goto('/login'); - - await page.fill('[placeholder="이메일 아이디"]', 'not-an-email'); - await page.fill('[placeholder="패스워드"]', 'Password123!'); - - await expect(page.getByRole('button', { name: '로그인' })).toBeDisabled(); + await page.fill('[placeholder="이메일 아이디"]', 'invalid-email'); + await expect(page.getByText('이메일 주소를 정확하게 입력해주세요.')).toBeVisible(); }); - test('둘러보기 버튼 클릭 시 홈으로 이동한다', async ({ page }) => { + test('비로그인 상태에서 둘러보기 버튼을 누르면 홈(/)으로 이동한다', async ({ page }) => { await page.goto('/login'); await page.getByText('둘러보기').click(); await expect(page).toHaveURL('/'); @@ -97,6 +190,11 @@ test.describe('로그아웃', () => { // ──────────────────────────────────────────────────────────── test.describe('이메일 회원가입', () => { + // 각 테스트 전에 MSW db 리셋 — new@test.com 재사용 가능하게 + test.beforeEach(async ({ request }) => { + await request.post('http://localhost:9090/api/test/reset'); + }); + test.describe('Step 1: 약관 동의 및 이메일 입력 (RegisterEmail)', () => { test('약관 동의 화면이 처음에 표시된다', async ({ page }) => { await page.goto('/registerEmail'); @@ -104,99 +202,30 @@ test.describe('이메일 회원가입', () => { }); test('약관 동의 후 이메일 입력 폼이 표시된다', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await expect(page.getByText('이메일 주소를 입력해주세요')).toBeVisible(); + await acceptTerms(page); + // acceptTerms 내부에서 이미 expect(getByText('이메일 주소를 입력해주세요')).toBeVisible() 확인됨 }); test('유효하지 않은 이메일 입력 시 다음 버튼이 비활성화된다', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - - await page.fill('[placeholder="이메일 입력"]', 'not-an-email'); + await acceptTerms(page); + await fillReactInput(page, '이메일 입력', 'not-an-email'); await expect(page.getByRole('button', { name: '다음' })).toBeDisabled(); }); test('이미 사용 중인 이메일 입력 시 에러 메시지가 표시된다', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - - await page.fill('[placeholder="이메일 입력"]', 'duplicate@test.com'); + await acceptTerms(page); + await fillReactInput(page, '이메일 입력', 'duplicate@test.com'); await page.getByRole('button', { name: '다음' }).click(); await expect(page.getByText('이미 사용중인 이메일입니다.')).toBeVisible(); }); test('사용 가능한 이메일 입력 후 인증 페이지로 이동한다', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await page.fill('[placeholder="이메일 입력"]', 'new@test.com'); - await page.getByRole('button', { name: '다음' }).click(); - - await expect(page).toHaveURL('/verifyEmail'); + await goToVerifyEmail(page); }); }); test.describe('Step 2: 이메일 인증 코드 (VerifyEmail)', () => { - // verifyEmail 진입 헬퍼 - const goToVerifyEmail = async (page: any) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await page.fill('[placeholder="이메일 입력"]', 'new@test.com'); - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/verifyEmail'); - }; - test('6자리 코드 입력 전에는 다음 버튼이 비활성화된다', async ({ page }) => { await goToVerifyEmail(page); await expect(page.getByRole('button', { name: '다음' })).toBeDisabled(); @@ -224,42 +253,11 @@ test.describe('이메일 회원가입', () => { }); test('올바른 인증 코드 입력 시 비밀번호 등록 페이지로 이동한다', async ({ page }) => { - await goToVerifyEmail(page); - - for (let i = 1; i <= 6; i++) { - await page.getByLabel(`${i}번째 숫자`).fill('1'); - } - await page.getByRole('button', { name: '다음' }).click(); - - await expect(page).toHaveURL('/registerPassword'); + await goToRegisterPassword(page); }); }); test.describe('Step 3: 비밀번호 등록 (RegisterPassword)', () => { - const goToRegisterPassword = async (page: any) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await page.fill('[placeholder="이메일 입력"]', 'new@test.com'); - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/verifyEmail'); - - for (let i = 1; i <= 6; i++) { - await page.getByLabel(`${i}번째 숫자`).fill('1'); - } - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/registerPassword'); - }; - test('비밀번호 규칙 위반 시 에러 메시지가 표시된다', async ({ page }) => { await goToRegisterPassword(page); @@ -293,6 +291,14 @@ test.describe('이메일 회원가입', () => { await expect(page).toHaveURL('/registerEmail'); }); }); + + test.describe('전체 플로우 — RegisterDone 자동 로그인', () => { + test('회원가입 완료 후 로그인 페이지가 아닌 홈(/)으로 이동한다', async ({ page }) => { + await goToRegisterDone(page); + // RegisterDone: 2초 후 router.replace("/") 동작 확인 + await expect(page).toHaveURL('/', { timeout: 5000 }); + }); + }); }); // ──────────────────────────────────────────────────────────── @@ -366,6 +372,7 @@ test.describe('접근성 (axe 베이스라인)', () => { await page.goto('/login'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .exclude('nextjs-portal') .analyze(); logAxeResults(results, '/login'); @@ -376,6 +383,7 @@ test.describe('접근성 (axe 베이스라인)', () => { await page.goto('/registerEmail'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .exclude('nextjs-portal') .analyze(); logAxeResults(results, '/registerEmail (약관 모달)'); @@ -383,22 +391,11 @@ test.describe('접근성 (axe 베이스라인)', () => { }); test('이메일 입력 페이지 접근성 검사 — 폼', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await expect(page.getByText('이메일 주소를 입력해주세요')).toBeVisible(); + await acceptTerms(page); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .exclude('nextjs-portal') .analyze(); logAxeResults(results, '/registerEmail (이메일 폼)'); @@ -406,24 +403,11 @@ test.describe('접근성 (axe 베이스라인)', () => { }); test('이메일 인증 페이지 접근성 검사', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await page.fill('[placeholder="이메일 입력"]', 'new@test.com'); - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/verifyEmail'); + await goToVerifyEmail(page); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .exclude('nextjs-portal') .analyze(); logAxeResults(results, '/verifyEmail'); @@ -431,30 +415,11 @@ test.describe('접근성 (axe 베이스라인)', () => { }); test('비밀번호 등록 페이지 접근성 검사', async ({ page }) => { - await page.goto('/registerEmail'); - // Terms 모달은 슬라이드업 애니메이션으로 viewport 아래에서 올라오므로 - // 애니메이션 완료 후 JS evaluate로 직접 클릭 - await page.waitForFunction( - () => !!Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled')) - ); - await page.evaluate(() => { - const btn = Array.from(document.querySelectorAll('button')).find( - b => b.textContent?.trim() === '동의합니다' && !b.hasAttribute('disabled') - ) as HTMLButtonElement | undefined; - btn?.click(); - }); - await page.fill('[placeholder="이메일 입력"]', 'new@test.com'); - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/verifyEmail'); - - for (let i = 1; i <= 6; i++) { - await page.getByLabel(`${i}번째 숫자`).fill('1'); - } - await page.getByRole('button', { name: '다음' }).click(); - await expect(page).toHaveURL('/registerPassword'); + await goToRegisterPassword(page); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .exclude('nextjs-portal') .analyze(); logAxeResults(results, '/registerPassword'); diff --git a/e2e/enrollment.spec.ts b/e2e/enrollment.spec.ts index 489c49d4..5f4546d5 100644 --- a/e2e/enrollment.spec.ts +++ b/e2e/enrollment.spec.ts @@ -1,15 +1,179 @@ /** - * [Draft] enrollment E2E 테스트 + * enrollment E2E 테스트 * - * Phase 4 (pages/widgets 레이어) 완료 후 실제 실행 예정. - * - 페이지 구조 확정 및 selector 검증 필요 + * API mock: MSW HTTP 서버 (localhost:9090) — playwright.config.ts의 webServer 설정 참조 + * + * 커버 범위: + * - 참가 신청 폼 (비로그인): 보내기 버튼 비활성/활성 상태 + * - 신청 목록 (호스트): 신청자 표시, 수락 플로우, 거절 플로우 + * - 접근성 axe 자동 검사 (/trip/apply/1) + * + * 실행: yarn test:e2e --project=chromium e2e/enrollment.spec.ts */ import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const SEED_TRIP_NUMBER = 1; +const APPLY_URL = `/trip/apply/${SEED_TRIP_NUMBER}`; +const ENROLLMENT_LIST_URL = `/trip/enrollmentList/${SEED_TRIP_NUMBER}`; + +// 신청 목록 테스트용 mock 데이터 +const mockEnrollments = { + enrollments: [ + { + enrollmentNumber: 1001, + userName: '신청자유저', + userAgeGroup: '20대', + enrolledAt: new Date().toISOString(), + message: '함께 여행하고 싶습니다!', + status: 'PENDING', + profileUrl: null, + }, + ], + totalCount: 1, +}; + +const SEED_TRIP_TITLE = '제주도 3박4일 같이 가요!'; + +const mockTripPage = { + content: [ + { + travelNumber: 1, + userNumber: 1, + userName: '테스트유저', + title: '제주도 3박4일 같이 가요!', + location: '제주도', + startDate: '2026-04-01', + endDate: '2026-04-04', + registerDue: '2026-04-04', + maxPerson: 4, + nowPerson: 1, + tags: ['국내', '단기', '여유'], + createdAt: new Date().toISOString(), + bookmarked: false, + loginMemberRelatedInfo: { hostUser: true, enrollmentNumber: null, bookmarked: false }, + }, + ], + page: { size: 10, number: 0, totalElements: 1, totalPages: 1 }, +}; + +/** 홈 페이지 API 요청을 mock하여 크래시 방지 */ +async function mockHomePageRoutes( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], +) { + await page.route('**/api/travels/recent**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: mockTripPage, error: null }), + }), + ); + await page.route('**/api/travels/recommend**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: mockTripPage, error: null }), + }), + ); + await page.route('**/api/bookmarks**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + resultType: 'SUCCESS', + success: { content: [], page: { size: 10, number: 0, totalElements: 0, totalPages: 0 } }, + error: null, + }), + }), + ); + await page.route('**/api/notifications**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + resultType: 'SUCCESS', + success: { content: [], page: { size: 10, number: 0, totalElements: 0, totalPages: 0 } }, + error: null, + }), + }), + ); +} -test.describe('참가 신청 (Enrollment)', () => { - test.describe('참가 신청', () => { - test('로그인한 사용자는 여행에 참가 신청할 수 있다', async ({ page }) => { - await page.route('**/api/enrollment', (route) => +/** + * User1(호스트) 로그인 후 홈 → trip detail → enrollment list 까지 client-side 이동. + * page.goto()를 사용하면 Zustand가 초기화되므로 client-side 이동이 필요. + */ +async function loginAndNavigateToEnrollmentList( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], +) { + await mockHomePageRoutes(page); + await page.goto('/login'); + await page.fill('[placeholder="이메일 아이디"]', 'test@test.com'); + await page.fill('[placeholder="패스워드"]', 'Password123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); + // 홈에서 trip detail로 client-side 이동 (Zustand 유지) + await expect(page.getByText(SEED_TRIP_TITLE).first()).toBeVisible({ timeout: 10000 }); + await page.getByText(SEED_TRIP_TITLE).first().click(); + await page.waitForURL(`/trip/detail/${SEED_TRIP_NUMBER}`); + // 호스트 버튼 확인 후 클릭 + await expect(page.getByRole('button', { name: '참가 신청 목록' })).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: '참가 신청 목록' }).click(); + await page.waitForURL(ENROLLMENT_LIST_URL); +} + +/** 신청 목록 페이지에서 필요한 API 경로 모킹 */ +async function mockEnrollmentListRoutes( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], +) { + await page.route(`**/api/travel/${SEED_TRIP_NUMBER}/enrollments`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: mockEnrollments, error: null }), + }), + ); + // GET last-viewed + PUT last-viewed 통합 처리 + await page.route(`**/api/travel/${SEED_TRIP_NUMBER}/enrollments/last-viewed`, (route) => { + if (route.request().method() === 'PUT') { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: true, error: null }), + }); + } else { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + resultType: 'SUCCESS', + success: { lastViewedAt: null }, + error: null, + }), + }); + } + }); +} + +// ──────────────────────────────────────────────────────────── +// 1. 참가 신청 폼 — 비로그인 +// ──────────────────────────────────────────────────────────── + +test.describe('참가 신청 폼', () => { + test('메시지가 없으면 보내기 버튼이 비활성화된다', async ({ page }) => { + await page.goto(APPLY_URL); + await expect(page.getByRole('button', { name: '보내기' })).toBeDisabled(); + }); + + test('메시지 입력 후 보내기 버튼이 활성화된다', async ({ page }) => { + await page.goto(APPLY_URL); + await page.locator('textarea:not([readonly])').fill('신청합니다!'); + await expect(page.getByRole('button', { name: '보내기' })).toBeEnabled(); + }); + + test('참가 신청 성공 후 상세 페이지로 이동한다', async ({ page }) => { + await page.route('**/api/enrollment', (route) => { + if (route.request().method() === 'POST') { route.fulfill({ status: 200, contentType: 'application/json', @@ -18,45 +182,108 @@ test.describe('참가 신청 (Enrollment)', () => { success: { enrollmentNumber: 10 }, error: null, }), - }) - ); - - await page.goto('/trip/apply/1'); - // TODO: 신청 폼 selector는 페이지 구조 확정 후 추가 + }); + } else { + route.fallback(); + } }); - test('참가 신청 취소가 가능하다', async ({ page }) => { - await page.route('**/api/enrollment/10', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ resultType: 'SUCCESS', success: null, error: null }), - }) - ); + // 로그인해서 httpOnly refreshToken 쿠키 설정 + await mockHomePageRoutes(page); + await page.goto('/login'); + await page.fill('[placeholder="이메일 아이디"]', 'test@test.com'); + await page.fill('[placeholder="패스워드"]', 'Password123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); - await page.goto('/trip/detail/1'); - // TODO: 취소 버튼 selector는 페이지 구조 확정 후 추가 - }); + // page.goto()로 apply 페이지 이동 → Zustand 초기화되지만 쿠키는 유지 + // AppShell이 !accessToken 감지 → userPostRefreshToken() 호출 → MSW가 새 accessToken 반환 + await page.goto(APPLY_URL); + await page.waitForResponse('**/api/token/refresh'); + await page.waitForLoadState('networkidle'); + + await page.locator('textarea:not([readonly])').fill('신청합니다!'); + await page.getByRole('button', { name: '보내기' }).click(); + + await expect(page).toHaveURL(`/trip/detail/${SEED_TRIP_NUMBER}`); }); +}); - test.describe('신청 목록 (호스트)', () => { - test('호스트는 신청 목록을 볼 수 있다', async ({ page }) => { - await page.route('**/api/travel/1/enrollments', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - resultType: 'SUCCESS', - success: { - enrollments: [{ enrollmentNumber: 1, userName: '신청자1', ageGroup: '20대', status: 'PENDING' }], - }, - error: null, - }), - }) - ); +// ──────────────────────────────────────────────────────────── +// 2. 신청 목록 — 호스트 (로그인 후 접근) +// ──────────────────────────────────────────────────────────── + +test.describe('신청 목록 — 호스트', () => { + test.beforeEach(async ({ page }) => { + await mockEnrollmentListRoutes(page); + await loginAndNavigateToEnrollmentList(page); + }); + + test('신청자 이름이 목록에 표시된다', async ({ page }) => { + await expect(page.getByText('신청자유저')).toBeVisible(); + }); + + test('수락 플로우: 수락하기 클릭 후 수락 완료 모달이 표시된다', async ({ page }) => { + await page.route(`**/api/enrollment/1001/acceptance`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: true, error: null }), + }), + ); + + await expect(page.getByText('신청자유저')).toBeVisible(); + + // 수락 버튼 클릭 → CheckingModal 오픈 + await page.getByText('수락').click(); + + // CheckingModal에서 수락하기 클릭 + await page.getByRole('button', { name: '수락하기' }).click(); - await page.goto('/trip/enrollmentList/1'); - await expect(page.getByText('신청자1')).toBeVisible(); + // ResultModal 확인 + await expect(page.getByText('참가 수락 완료')).toBeVisible(); + }); + + test('거절 플로우: 거절하기 클릭 후 거절 토스트가 표시된다', async ({ page }) => { + await page.route(`**/api/enrollment/1001/rejection`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: true, error: null }), + }), + ); + + await expect(page.getByText('신청자유저')).toBeVisible(); + + // 거절 버튼 클릭 → CheckingModal 오픈 + await page.getByText('거절', { exact: true }).click(); + + // CheckingModal에서 거절하기 클릭 + await page.getByRole('button', { name: '거절하기' }).click(); + + // 거절 완료 토스트 확인 + await expect(page.getByText('여행 참가가 거절되었어요.')).toBeVisible(); + }); +}); + +// ──────────────────────────────────────────────────────────── +// 3. 접근성 (axe) +// ──────────────────────────────────────────────────────────── + +test.describe('접근성 — 참가 신청 폼', () => { + test('/trip/apply/1 접근성 위반을 측정한다', async ({ page }) => { + await page.goto(APPLY_URL); + await page.locator('textarea:not([readonly])').waitFor({ state: 'visible' }); + + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + + console.log(`[axe] /trip/apply/1 위반 수: ${results.violations.length}`); + results.violations.forEach((v) => { + console.log(` - [${v.impact}] ${v.id}: ${v.description}`); }); + + expect(results.violations).toBeDefined(); }); }); diff --git a/e2e/home-debug.spec.ts b/e2e/home-debug.spec.ts new file mode 100644 index 00000000..6fe1c617 --- /dev/null +++ b/e2e/home-debug.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; +test('home page shows content', async ({ page }) => { + await page.goto('/'); + await page.waitForTimeout(3000); + const snapshot = await page.accessibility.snapshot(); + console.log('HOME PAGE CONTENT:', JSON.stringify(snapshot?.children?.slice(0, 5))); +}); diff --git a/e2e/trip.spec.ts b/e2e/trip.spec.ts index 8c954968..c284270d 100644 --- a/e2e/trip.spec.ts +++ b/e2e/trip.spec.ts @@ -1,92 +1,108 @@ /** - * [Draft] trip E2E 테스트 + * trip E2E 테스트 * - * Phase 4 (pages/widgets 레이어) 완료 후 실제 실행 예정. - * - 페이지 구조 확정 및 selector 검증 필요 - * - API 응답 포맷을 실제 서버 스펙에 맞게 조정 필요 + * API mock: MSW HTTP 서버 (localhost:9090) — playwright.config.ts의 webServer 설정 참조 + * + * 커버 범위: + * - 여행 목록 조회 (비로그인) + * - 여행 아이템 클릭 → 상세 페이지 이동 + * - 접근성 axe 자동 검사 (/trip/list) + * - 성능 메트릭 베이스라인 (/trip/list, /trip/detail/1) + * + * 실행: yarn test:e2e --project=chromium e2e/trip.spec.ts */ import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +// 시드 데이터: src/mocks/db/store.ts seedDatabase() 기준 +const SEED_TRIP_TITLE = '제주도 3박4일 같이 가요!'; +const SEED_TRIP_NUMBER = 1; -const mockTripList = { - resultType: 'SUCCESS', - success: { - content: [ - { - travelNumber: 1, - title: '유럽 배낭여행', - userNumber: 1, - userName: '테스터', - tags: ['유럽', '배낭여행'], - nowPerson: 2, - maxPerson: 4, - createdAt: '2024-01-01T00:00:00', - registerDue: '2024-12-31', - location: '프랑스', - bookmarked: false, - }, - ], - page: { size: 10, number: 0, totalElements: 1, totalPages: 1 }, - }, - error: null, -}; +// ──────────────────────────────────────────────────────────── +// 1. 여행 목록 (Trip List) +// ──────────────────────────────────────────────────────────── test.describe('여행 목록 (Trip List)', () => { - test.describe('홈 여행 목록', () => { - test('최근 여행 목록이 표시된다', async ({ page }) => { - await page.route('**/api/travels/recent*', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(mockTripList), - }) - ); - - await page.goto('/trip/list'); - await expect(page.getByText('유럽 배낭여행')).toBeVisible(); - }); + test('최근 여행 목록에 시드 여행이 표시된다', async ({ page }) => { + await page.goto('/trip/list'); + await expect(page.getByText(SEED_TRIP_TITLE)).toBeVisible(); + }); - test('추천 여행 탭 클릭 시 추천 목록이 표시된다', async ({ page }) => { - await page.route('**/api/travels/recommend*', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(mockTripList), - }) - ); - - await page.goto('/trip/list?sort=recommend'); - await expect(page.getByText('유럽 배낭여행')).toBeVisible(); - }); + test('추천순 탭 전환 시 여행 목록이 표시된다', async ({ page }) => { + await page.goto('/trip/list?sort=recommend'); + await expect(page.getByText(SEED_TRIP_TITLE)).toBeVisible(); + }); - test('여행 아이템 클릭 시 상세 페이지로 이동한다', async ({ page }) => { - await page.route('**/api/travels/recent*', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(mockTripList), - }) - ); - - await page.goto('/trip/list'); - await page.click('text=유럽 배낭여행'); - await expect(page).toHaveURL('/trip/detail/1'); - }); + test('여행 아이템 클릭 시 상세 페이지로 이동한다', async ({ page }) => { + await page.goto('/trip/list'); + await page.getByText(SEED_TRIP_TITLE).click(); + await expect(page).toHaveURL(`/trip/detail/${SEED_TRIP_NUMBER}`); }); +}); + +// ──────────────────────────────────────────────────────────── +// 2. 접근성 (axe) +// ──────────────────────────────────────────────────────────── + +test.describe('접근성 — 여행 목록 페이지', () => { + test('/trip/list 접근성 위반을 측정한다', async ({ page }) => { + await page.goto('/trip/list'); + await expect(page.getByText(SEED_TRIP_TITLE)).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); - test.describe('인기 여행 장소', () => { - test('5개의 인기 장소가 표시된다', async ({ page }) => { - await page.goto('/'); - await expect(page.getByText('뉴욕')).toBeVisible(); - await expect(page.getByText('제주도')).toBeVisible(); - await expect(page.getByText('도쿄')).toBeVisible(); - await expect(page.getByText('파리')).toBeVisible(); - await expect(page.getByText('서울')).toBeVisible(); + console.log(`[axe] /trip/list 위반 수: ${results.violations.length}`); + results.violations.forEach((v) => { + console.log(` - [${v.impact}] ${v.id}: ${v.description}`); }); - test('장소 클릭 시 검색 페이지로 이동한다', async ({ page }) => { - await page.goto('/'); - await page.click('text=뉴욕'); - await expect(page).toHaveURL('/search/travel?keyword=뉴욕'); + expect(results.violations).toBeDefined(); + }); +}); + +// ──────────────────────────────────────────────────────────── +// 3. 성능 메트릭 (베이스라인) +// ──────────────────────────────────────────────────────────── + +test.describe('성능 메트릭 (베이스라인)', () => { + const measurePerf = async ( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], + url: string, + label: string, + ) => { + await page.goto(url, { waitUntil: 'networkidle' }); + + const metrics = await page.evaluate(() => { + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + const paint = performance.getEntriesByType('paint'); + const fcp = paint.find((p) => p.name === 'first-contentful-paint'); + + return { + ttfb: Math.round(nav.responseStart - nav.requestStart), + domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime), + load: Math.round(nav.loadEventEnd - nav.startTime), + fcp: fcp ? Math.round(fcp.startTime) : null, + }; }); + + console.log(`\n[Perf] ${label} (${url})`); + console.log(` TTFB: ${metrics.ttfb}ms`); + console.log(` FCP: ${metrics.fcp ?? 'N/A'}ms`); + console.log(` DOMContentLoaded: ${metrics.domContentLoaded}ms`); + console.log(` Load: ${metrics.load}ms`); + + return metrics; + }; + + test('여행 목록 페이지 성능 측정', async ({ page }) => { + const metrics = await measurePerf(page, '/trip/list', '여행 목록'); + expect(metrics).toBeDefined(); + }); + + test('여행 상세 페이지 성능 측정', async ({ page }) => { + const metrics = await measurePerf(page, `/trip/detail/${SEED_TRIP_NUMBER}`, '여행 상세'); + expect(metrics).toBeDefined(); }); }); diff --git a/e2e/tripDetail.spec.ts b/e2e/tripDetail.spec.ts index 38f9a78f..1fa10341 100644 --- a/e2e/tripDetail.spec.ts +++ b/e2e/tripDetail.spec.ts @@ -1,99 +1,200 @@ /** - * [Draft] tripDetail E2E 테스트 + * tripDetail E2E 테스트 * - * Phase 4 (pages/widgets 레이어) 완료 후 실제 실행 예정. - * - 페이지 구조 확정 및 selector 검증 필요 - * - API 응답 포맷을 실제 서버 스펙에 맞게 조정 필요 + * API mock: MSW HTTP 서버 (localhost:9090) + * + * 커버 범위: + * - 상세 조회 (비로그인): 제목 표시, 참가 신청 하기 버튼 + * - 상세 조회 (호스트): 로그인 후 client-side 이동 → React Query refetch → hostUser=true 확인 + * - 접근성 axe 자동 검사 (/trip/detail/1) + * + * 호스트 테스트 설계 배경: + * - trip/detail/[id] page.tsx 는 서버사이드 HydrationBoundary 프리페치를 사용한다. + * - 서버사이드는 accessToken=null 로 호출 → loginMemberRelatedInfo: null → hostUserCheck: false. + * - useTripDetail 쿼리의 enabled 조건: isGuestUser || !!accessToken. + * - 비로그인 상태에서는 enabled=false → 클라이언트 refetch 없음. + * - 따라서 호스트 버튼은 "로그인 → 홈에서 trip 카드 클릭(client-side 이동)" 방식으로만 표시된다. + * + * 실행: yarn test:e2e --project=chromium e2e/tripDetail.spec.ts */ import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; -const mockTripDetail = { - resultType: 'SUCCESS', - success: { - travelNumber: 1, - title: '유럽 배낭여행', - location: '프랑스', - details: '즐거운 유럽 여행입니다.', - maxPerson: 4, - nowPerson: 2, - genderType: '모두', - startDate: '2024-06-01', - endDate: '2024-06-10', - tags: ['유럽', '배낭여행'], - hostUserCheck: false, - bookmarked: false, - userName: '테스터', - }, - error: null, +const SEED_TRIP_NUMBER = 1; +const SEED_TRIP_TITLE = '제주도 3박4일 같이 가요!'; +const DETAIL_URL = `/trip/detail/${SEED_TRIP_NUMBER}`; + +const mockTripPage = { + content: [ + { + travelNumber: 1, + userNumber: 1, + userName: '테스트유저', + title: '제주도 3박4일 같이 가요!', + location: '제주도', + startDate: '2026-04-01', + endDate: '2026-04-04', + registerDue: '2026-04-04', + maxPerson: 4, + nowPerson: 1, + tags: ['국내', '단기', '여유'], + createdAt: new Date().toISOString(), + bookmarked: false, + loginMemberRelatedInfo: { hostUser: true, enrollmentNumber: null, bookmarked: false }, + }, + ], + page: { size: 10, number: 0, totalElements: 1, totalPages: 1 }, }; -test.describe('여행 상세 (Trip Detail)', () => { - test.describe('상세 조회', () => { - test('여행 제목이 표시된다', async ({ page }) => { - await page.route('**/api/travel/detail/1', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(mockTripDetail), - }) - ); - await page.route('**/api/travel/1/enrollmentCount', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ resultType: 'SUCCESS', success: { count: 2 }, error: null }), - }) - ); - await page.route('**/api/travel/1/companions', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ resultType: 'SUCCESS', success: { companions: [] }, error: null }), - }) - ); +/** 홈 페이지 API 요청을 mock하여 크래시 방지 */ +async function mockHomePageRoutes( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], +) { + await page.route('**/api/travels/recent**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: mockTripPage, error: null }), + }), + ); + await page.route('**/api/travels/recommend**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resultType: 'SUCCESS', success: mockTripPage, error: null }), + }), + ); + await page.route('**/api/bookmarks**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + resultType: 'SUCCESS', + success: { content: [], page: { size: 10, number: 0, totalElements: 0, totalPages: 0 } }, + error: null, + }), + }), + ); + await page.route('**/api/notifications**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + resultType: 'SUCCESS', + success: { content: [], page: { size: 10, number: 0, totalElements: 0, totalPages: 0 } }, + error: null, + }), + }), + ); +} - await page.goto('/trip/detail/1'); - await expect(page.getByText('유럽 배낭여행')).toBeVisible(); - }); +/** User1(호스트) 로그인 후 홈에서 client-side로 trip detail로 이동 */ +async function loginAndNavigateToTripDetail( + page: Parameters<Parameters<typeof test>[1]>[0]['page'], +) { + await mockHomePageRoutes(page); + await page.goto('/login'); + await page.fill('[placeholder="이메일 아이디"]', 'test@test.com'); + await page.fill('[placeholder="패스워드"]', 'Password123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('/'); + // 홈 페이지에서 시드 여행 카드 클릭 (Next.js Link → client-side 이동, Zustand 유지) + await expect(page.getByText(SEED_TRIP_TITLE).first()).toBeVisible({ timeout: 10000 }); + await page.getByText(SEED_TRIP_TITLE).first().click(); + await page.waitForURL(DETAIL_URL); +} + +// ──────────────────────────────────────────────────────────── +// 1. 여행 상세 조회 — 비로그인 +// ──────────────────────────────────────────────────────────── + +test.describe('여행 상세 — 비로그인', () => { + test('여행 제목이 표시된다', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect(page.getByText(SEED_TRIP_TITLE)).toBeVisible(); + }); + + test('참가 신청 하기 버튼이 표시된다', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect(page.getByRole('button', { name: '참가 신청 하기' })).toBeVisible(); + }); +}); + +// ──────────────────────────────────────────────────────────── +// 2. 여행 상세 조회 — 호스트 (로그인 후 client-side 이동) +// ──────────────────────────────────────────────────────────── - test('참가 신청 버튼이 표시된다', async ({ page }) => { - await page.route('**/api/travel/detail/1', (route) => +test.describe('여행 상세 — 호스트', () => { + test('참가 신청 목록 버튼이 표시된다', async ({ page }) => { + await loginAndNavigateToTripDetail(page); + await expect(page.getByRole('button', { name: '참가 신청 목록' })).toBeVisible(); + }); + + test('여행을 삭제할 수 있다', async ({ page }) => { + await page.route(`**/api/travel/${SEED_TRIP_NUMBER}`, (route) => { + if (route.request().method() === 'DELETE') { route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(mockTripDetail), - }) - ); + body: JSON.stringify({ resultType: 'SUCCESS', success: true, error: null }), + }); + } else { + route.fallback(); + } + }); + + await loginAndNavigateToTripDetail(page); + await expect(page.getByRole('button', { name: '참가 신청 목록' })).toBeVisible(); + + // 우상단 ··· 버튼 클릭 → EditAndDeleteModal 오픈 (header 내 마지막 .w-12.h-12 = MoreIcon) + const moreButton = page.locator('header').locator('.w-12.h-12').last(); + await moreButton.click(); + + // EditAndDeleteModal(#end-modal 포탈)에서 "삭제하기" 클릭 + await page.locator('#end-modal').getByRole('button', { name: '삭제하기' }).click(); + + // CheckingModal(#checking-modal 포탈)에서 "삭제하기" 클릭 + await page.locator('#checking-modal').getByRole('button', { name: '삭제하기' }).click(); + + // 삭제 완료 토스트 확인 + await expect(page.getByText('여행 게시글이 삭제되었어요.')).toBeVisible(); + }); +}); + +// ──────────────────────────────────────────────────────────── +// 3. 접근성 (axe) +// ──────────────────────────────────────────────────────────── - await page.goto('/trip/detail/1'); - await expect(page.getByRole('button', { name: '참가 신청 하기' })).toBeVisible(); +test.describe('접근성 — 여행 상세 페이지', () => { + test('/trip/detail/1 비로그인 접근성 위반을 측정한다', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect(page.getByText(SEED_TRIP_TITLE)).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + + console.log(`[axe] /trip/detail/1 (비로그인) 위반 수: ${results.violations.length}`); + results.violations.forEach((v) => { + console.log(` - [${v.impact}] ${v.id}: ${v.description}`); }); + + expect(results.violations).toBeDefined(); }); - test.describe('여행 삭제', () => { - test('호스트는 여행을 삭제할 수 있다', async ({ page }) => { - const hostMockDetail = { - ...mockTripDetail, - success: { ...mockTripDetail.success, hostUserCheck: true }, - }; + test('/trip/detail/1 호스트 접근성 위반을 측정한다', async ({ page }) => { + await loginAndNavigateToTripDetail(page); + await expect(page.getByRole('button', { name: '참가 신청 목록' })).toBeVisible(); - await page.route('**/api/travel/detail/1', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(hostMockDetail), - }) - ); - await page.route('**/api/travel/1', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ resultType: 'SUCCESS', success: null, error: null }), - }) - ); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); - await page.goto('/trip/detail/1'); - // TODO: 삭제 버튼 selector는 페이지 구조 확정 후 추가 + console.log(`[axe] /trip/detail/1 (호스트) 위반 수: ${results.violations.length}`); + results.violations.forEach((v) => { + console.log(` - [${v.impact}] ${v.id}: ${v.description}`); }); + + expect(results.violations).toBeDefined(); }); }); diff --git a/next.config.js b/next.config.js index 29f0c3f0..48705d2f 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,10 @@ const nextConfig = { distDir: ".next", images: { - domains: [], // 필요한 외부 이미지 도메인 추가 + remotePatterns: [ + { protocol: 'http', hostname: '125.242.221.180', port: '8080', pathname: '/**' }, + { protocol: 'https', hostname: '**', pathname: '/**' }, + ], }, experimental: { scrollRestoration: true, diff --git a/src/app/globals.css b/src/app/globals.css index 7dd1187c..eb2db94c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ --font-mitr: "Mitr", sans-serif; /* 브랜드 색상 */ - --color-keycolor: rgba(62, 141, 0, 1); + --color-keycolor: rgba(45, 122, 0, 1); /* #2d7a00 — white 5.41:1 / keycolor-bg 4.57:1 */ --color-keycolor-bg: #E3EFD9; --color-button-active: #F1F7EC; --color-button-hover: rgba(241, 247, 236, 1); @@ -14,8 +14,8 @@ /* 텍스트 색상 */ --color-text-base: rgba(26, 26, 26, 1); - --color-text-muted: rgba(132, 132, 132, 1); - --color-text-muted2: rgba(171, 171, 171, 1); + --color-text-muted: rgba(107, 107, 107, 1); /* #6b6b6b — #fdfdfd 배경 기준 5.24:1 */ + --color-text-muted2: rgba(113, 113, 113, 1); /* #717171 — #fdfdfd 배경 기준 4.80:1 */ /* 배경 / 경계 색상 */ --color-muted3: rgba(205, 205, 205, 1); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e179525d..98558857 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import Login from "@/page/Login/Login"; -import React from "react"; + +export const metadata: Metadata = { + title: "로그인 | 모잉", + description: "모잉에 로그인하고 여행 메이트를 만나보세요.", +}; const LoginPage = () => { return <Login />; diff --git a/src/app/registerEmail/page.tsx b/src/app/registerEmail/page.tsx index faaf68fd..b78ca010 100644 --- a/src/app/registerEmail/page.tsx +++ b/src/app/registerEmail/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import RegisterEmail from "@/page/Register/RegisterEmail"; -import React from "react"; + +export const metadata: Metadata = { + title: "회원가입 | 모잉", + description: "이메일로 모잉 계정을 만들어보세요.", +}; const RegisterEmailPage = () => { return <RegisterEmail />; diff --git a/src/app/registerPassword/page.tsx b/src/app/registerPassword/page.tsx index b0fe7f48..2673d794 100644 --- a/src/app/registerPassword/page.tsx +++ b/src/app/registerPassword/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import RegisterPassword from "@/page/Register/RegisterPassword"; -import React from "react"; + +export const metadata: Metadata = { + title: "비밀번호 설정 | 모잉", + description: "모잉 계정에 사용할 비밀번호를 설정해주세요.", +}; const RegisterPasswordPage = () => { return <RegisterPassword />; diff --git a/src/app/trip/apply/[travelNumber]/page.tsx b/src/app/trip/apply/[travelNumber]/page.tsx index 0843cdfc..b3665b51 100644 --- a/src/app/trip/apply/[travelNumber]/page.tsx +++ b/src/app/trip/apply/[travelNumber]/page.tsx @@ -1,5 +1,9 @@ import ApplyTrip from "@/page/ApplyTrip"; -import React from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "참가 신청 | 모잉", +}; const ApplyTripPage = () => { return <ApplyTrip />; diff --git a/src/app/trip/detail/[travelNumber]/page.tsx b/src/app/trip/detail/[travelNumber]/page.tsx index 632d1b39..9ba8fc8a 100644 --- a/src/app/trip/detail/[travelNumber]/page.tsx +++ b/src/app/trip/detail/[travelNumber]/page.tsx @@ -1,4 +1,4 @@ -import { getTripDetail, getTripEnrollmentCount } from "@/entities/tripDetail"; +import { getTripDetail, getTripEnrollmentCount, getCompanions } from "@/entities/tripDetail"; import { TripDetailPage } from "@/page-views/trip"; import { dehydrate, @@ -53,6 +53,10 @@ const Page = async ({ queryKey: ["tripEnrollment", travelNumber], queryFn: () => getTripEnrollmentCount(travelNumber, null), }), + queryClient.prefetchQuery({ + queryKey: ["companions", travelNumber], + queryFn: () => getCompanions(travelNumber, null), + }), ]); return ( diff --git a/src/app/trip/list/page.tsx b/src/app/trip/list/page.tsx index bb033e74..ebbb2878 100644 --- a/src/app/trip/list/page.tsx +++ b/src/app/trip/list/page.tsx @@ -1,8 +1,41 @@ +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; +import { getAvailableTrips, getRecommendationTrips } from "@/entities/trip"; import TripList from "@/page/TripList/TripList"; -import React from "react"; +import type { Metadata } from "next"; -const TripListPage = () => { - return <TripList />; +export const metadata: Metadata = { + title: "여행 목록 | 모잉", + description: "함께할 동행자를 찾는 여행 모집 목록", }; -export default TripListPage; +const getNextPageParam = (lastPage: any) => { + if (lastPage?.page?.number + 1 === lastPage?.page?.totalPages) { + return undefined; + } + return lastPage?.page?.number + 1; +}; + +export default async function Page() { + const queryClient = new QueryClient(); + + await Promise.all([ + queryClient.prefetchInfiniteQuery({ + queryKey: ["availableTrips"], + queryFn: ({ pageParam }) => getAvailableTrips(pageParam as number, null), + initialPageParam: 0, + getNextPageParam, + }), + queryClient.prefetchInfiniteQuery({ + queryKey: ["tripRecommendation"], + queryFn: ({ pageParam }) => getRecommendationTrips(pageParam as number, null), + initialPageParam: 0, + getNextPageParam, + }), + ]); + + return ( + <HydrationBoundary state={dehydrate(queryClient)}> + <TripList /> + </HydrationBoundary> + ); +} diff --git a/src/app/verifyEmail/page.tsx b/src/app/verifyEmail/page.tsx index 90fd8d7a..7f720eea 100644 --- a/src/app/verifyEmail/page.tsx +++ b/src/app/verifyEmail/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import VerifyEmail from "@/page/Register/VerifyEmail"; -import React from "react"; + +export const metadata: Metadata = { + title: "이메일 인증 | 모잉", + description: "이메일로 전송된 인증 코드를 입력해주세요.", +}; const VerifyEmailPage = () => { return <VerifyEmail />; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 743061f1..bc160ba3 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -115,7 +115,8 @@ const AppShell = ({ children }: { children: React.ReactNode }) => { }} > <Splash /> - <div + <main + id="main-content" className="relative h-full overscroll-none no-scrollbar w-svw min-[440px]:w-[390px] min-[440px]:overflow-x-hidden" style={{ backgroundColor: bodyBgColor }} > @@ -129,7 +130,7 @@ const AppShell = ({ children }: { children: React.ReactNode }) => { !checkRoute.startsWith(ROUTES.SEARCH.PLACE) && <Header />} {children} <Navbar /> - </div> + </main> </div> ); }; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2d415ad1..6624aebd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -25,9 +25,9 @@ const Layout = ({ children }: { children: React.ReactNode }) => { if (isAuthRoute(pathname)) { return ( <div className="overflow-x-hidden flex justify-center items-center h-svh w-svw"> - <div className="relative h-full overscroll-none no-scrollbar w-svw min-[440px]:w-[390px] min-[440px]:overflow-x-hidden bg-[var(--color-bg)]"> + <main className="relative h-full overscroll-none no-scrollbar w-svw min-[440px]:w-[390px] min-[440px]:overflow-x-hidden bg-[var(--color-bg)]"> {children} - </div> + </main> </div> ); } diff --git a/src/components/icons/ShareIcon.tsx b/src/components/icons/ShareIcon.tsx index 58278b22..2404ea59 100644 --- a/src/components/icons/ShareIcon.tsx +++ b/src/components/icons/ShareIcon.tsx @@ -6,9 +6,11 @@ import { useState } from "react"; interface ShareIconProps { width?: number; height?: number; + className?: string; + ariaLabel?: string; } -export default function ShareIcon({ width = 16, height = 20 }: ShareIconProps) { +export default function ShareIcon({ width = 16, height = 20, className, ariaLabel = "공유" }: ShareIconProps) { const [isToastShow, setIsToastShow] = useState(false); // 삭제 완료 메시지. if (typeof window === "undefined") { return null; @@ -16,7 +18,7 @@ export default function ShareIcon({ width = 16, height = 20 }: ShareIconProps) { return ( <div> <CopyToClipboard text={`${window?.location.href}?share=true`} onCopy={() => setIsToastShow(true)}> - <button> + <button type="button" aria-label={ariaLabel} className={className}> <svg width={width} height={height} viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M1 11V19C1 19.5304 1.21071 20.0391 1.58579 20.4142C1.96086 20.7893 2.46957 21 3 21H15C15.5304 21 16.0391 20.7893 16.4142 20.4142C16.7893 20.0391 17 19.5304 17 19V11" diff --git a/src/entities/user/api.ts b/src/entities/user/api.ts index f046c1a9..c4e3b1c3 100644 --- a/src/entities/user/api.ts +++ b/src/entities/user/api.ts @@ -1,7 +1,7 @@ import { axiosInstance, handleApiResponse } from '@/shared/api'; import RequestError from '@/context/ReqeustError'; import { getJWTHeader } from '@/utils/user'; -import { TravelLog } from './model'; +import { TravelLog, OAuthTokenResponse } from './model'; export async function getUser(userId: number, accessToken: string) { try { @@ -86,7 +86,7 @@ export const getToken = async ( window.location.href = response.data.success.redirectUrl; } - return handleApiResponse(response); + return handleApiResponse<OAuthTokenResponse>(response); } catch (error) { console.error('토큰 요청 실패:', error); } diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index b29f46e7..4c06e43d 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,4 +1,4 @@ -export type { IRegisterEmail, IRegisterGoogle, IRegisterKakao, TravelLog } from './model'; +export type { IRegisterEmail, IRegisterGoogle, IRegisterKakao, TravelLog, OAuthTokenResponse } from './model'; export { getUser, kakaoLogin, diff --git a/src/entities/user/model.ts b/src/entities/user/model.ts index a8b4ceea..72042d90 100644 --- a/src/entities/user/model.ts +++ b/src/entities/user/model.ts @@ -1,3 +1,16 @@ +// OAuth 토큰 콜백 응답 타입 +export interface OAuthTokenResponse { + userStatus: 'ABLE' | 'PENDING' | 'BLOCK'; + userNumber?: number; + userName?: string; + /** Google/Kakao 응답 필드 */ + userEmail?: string; + /** Naver 응답 필드 */ + email?: string; + socialLoginId?: string; + redirectUrl?: string; +} + // 회원가입 타입 (model/auth.ts) export interface IRegisterEmail { email: string; diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts index ae0aa22e..adcb5238 100644 --- a/src/features/auth/hooks/useAuth.ts +++ b/src/features/auth/hooks/useAuth.ts @@ -33,8 +33,9 @@ const useAuth = () => { mutationKey: ["emailLogin"], onSuccess: (data) => { setLoginData({ userId: Number(data.userId), accessToken: data.accessToken }); + const redirectPath = localStorage.getItem("loginPath") || "/"; localStorage.removeItem("loginPath"); - router.push("/"); + router.push(redirectPath); }, }); @@ -54,8 +55,9 @@ const useAuth = () => { }), onSuccess: (data) => { setLoginData({ userId: Number(data.userId), accessToken: data.accessToken }); + const redirectPath = localStorage.getItem("loginPath") || "/"; localStorage.removeItem("loginPath"); - router.push("/"); + router.push(redirectPath); }, }); diff --git a/src/features/auth/ui/EmailLoginForm.tsx b/src/features/auth/ui/EmailLoginForm.tsx index d81f3b1a..0df6c482 100644 --- a/src/features/auth/ui/EmailLoginForm.tsx +++ b/src/features/auth/ui/EmailLoginForm.tsx @@ -62,10 +62,14 @@ const EmailLoginForm = () => { placeholder="이메일 아이디" height={54} showIcon={true} + hasError={!!errors.email && (emailValue?.length ?? 0) > 0} success={!errors.email && (emailValue?.length ?? 0) > 0} showSuccessIcon={false} {...register('email')} /> + {errors.email && (emailValue?.length ?? 0) > 0 && ( + <InfoText hasError>{errors.email.message}</InfoText> + )} <Spacing size={16} /> <StateInputField showSuccessIcon={false} @@ -88,7 +92,7 @@ const EmailLoginForm = () => { )} <Spacing size={24} /> <div className="flex justify-center gap-[6px] items-center"> - <span style={{ color: '#848484' }}>처음 오셨나요?</span> + <span className="text-[var(--color-text-muted)]">처음 오셨나요?</span> <Link href="/registerEmail" style={{ textDecoration: 'underline' }}> 회원가입 </Link> diff --git a/src/features/enrollment/hooks/useEnrollment.ts b/src/features/enrollment/hooks/useEnrollment.ts index 7963be3a..08c41ef9 100644 --- a/src/features/enrollment/hooks/useEnrollment.ts +++ b/src/features/enrollment/hooks/useEnrollment.ts @@ -11,6 +11,7 @@ import { } from "@/entities/enrollment"; import { authStore } from "@/store/client/authStore"; import { tripDetailStore } from "@/store/client/tripDetailStore"; +import { createMutationOptions } from "@/shared/lib/errors"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const useEnrollment = (travelNumber: number) => { @@ -106,7 +107,10 @@ const useEnrollment = (travelNumber: number) => { }; const cancelMutation = useMutation({ - mutationFn: (enrollmentNumber: number) => cancelEnrollment(enrollmentNumber, accessToken), + ...createMutationOptions({ + mutationFn: (enrollmentNumber: number) => cancelEnrollment(enrollmentNumber, accessToken), + policy: { network: 'toast', system: 'toast' }, + }), }); const cancel = (enrollmentNumber: number) => { diff --git a/src/features/notification/hooks/useNotification.ts b/src/features/notification/hooks/useNotification.ts index 7c3950d2..846838c3 100644 --- a/src/features/notification/hooks/useNotification.ts +++ b/src/features/notification/hooks/useNotification.ts @@ -15,10 +15,10 @@ const useNotification = () => { initialPageParam: 0, staleTime: 0, getNextPageParam: (lastPage) => { - if (lastPage.page.number + 1 === lastPage.page.totalPages) { + if ((lastPage?.page?.number ?? 0) + 1 === lastPage?.page?.totalPages) { return undefined; } else { - return lastPage?.page.number + 1; + return (lastPage?.page?.number ?? 0) + 1; } }, queryFn: ({ pageParam }) => getNotifications(pageParam as number, accessToken!) as any, diff --git a/src/features/tripDetail/hooks/useTripDetail.ts b/src/features/tripDetail/hooks/useTripDetail.ts index 7116fd8b..c132a975 100644 --- a/src/features/tripDetail/hooks/useTripDetail.ts +++ b/src/features/tripDetail/hooks/useTripDetail.ts @@ -8,6 +8,7 @@ import { } from "@/entities/tripDetail"; import { UpdateTripReqData } from "@/entities/trip"; import { authStore } from "@/store/client/authStore"; +import { createMutationOptions } from "@/shared/lib/errors"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const useTripDetail = (travelNumber: number) => { @@ -36,9 +37,10 @@ const useTripDetail = (travelNumber: number) => { mutateAsync: updateTripDetailMutation, isSuccess: isEditSuccess, } = useMutation({ - mutationFn: (data: UpdateTripReqData) => { - return updateTripDetail(travelNumber, data, accessToken); - }, + ...createMutationOptions({ + mutationFn: (data: UpdateTripReqData) => updateTripDetail(travelNumber, data, accessToken), + policy: { network: 'toast', system: 'toast' }, + }), onSuccess: () => { queryClient.refetchQueries({ queryKey: ["tripDetail", travelNumber], @@ -47,25 +49,15 @@ const useTripDetail = (travelNumber: number) => { }); const { mutateAsync: deleteTripDetailMutation } = useMutation({ - mutationFn: () => { - return deleteTripDetail(travelNumber, accessToken); - }, + ...createMutationOptions({ + mutationFn: () => deleteTripDetail(travelNumber, accessToken), + policy: { network: 'toast', system: 'toast' }, + }), onSuccess: () => { - // 내가 만든 여행을 내 여행에서 삭제 가능하므로, 삭제시 무효화시킴. - // queryClient.invalidateQueries({ - // queryKey: ['tripDetail', travelNumber] - // }), - queryClient.refetchQueries({ - queryKey: ["tripRecommendation"], - }); - queryClient.refetchQueries({ - queryKey: ["availableTrips"], - }); - + queryClient.refetchQueries({ queryKey: ["tripRecommendation"] }); + queryClient.refetchQueries({ queryKey: ["availableTrips"] }); setTimeout(() => { - queryClient.invalidateQueries({ - queryKey: ["myTrips"], - }); + queryClient.invalidateQueries({ queryKey: ["myTrips"] }); }, 1500); }, }); diff --git a/src/mocks/db/store.ts b/src/mocks/db/store.ts index 609e2763..951256d8 100644 --- a/src/mocks/db/store.ts +++ b/src/mocks/db/store.ts @@ -189,6 +189,30 @@ export const db = { ), now: () => new Date().toISOString(), + + /** E2E 테스트 격리용 — 모든 동적 데이터 삭제 후 시드 데이터 재등록 */ + reset: () => { + db.users.clear(); + db.sessions.clear(); + db.refreshTokens.clear(); + db.emailVerifications.clear(); + db.trips.clear(); + db.enrollments.clear(); + db.bookmarks.clear(); + db.communityPosts.clear(); + db.communityLikes.clear(); + db.comments.clear(); + db.commentLikes.clear(); + db.notifications.clear(); + db.blockedEmails.clear(); + counters.user.v = 10; + counters.trip.v = 100; + counters.enrollment.v = 1000; + counters.community.v = 200; + counters.comment.v = 500; + counters.notification.v = 300; + seedDatabase(); + }, }; // ── Seed Data ────────────────────────────────────────────────────────────── diff --git a/src/mocks/http.ts b/src/mocks/http.ts index b714a38f..3bf9dd82 100644 --- a/src/mocks/http.ts +++ b/src/mocks/http.ts @@ -5,6 +5,7 @@ import authRouter from './routes/auth'; import tripRouter from './routes/trip'; import communityRouter from './routes/community'; import miscRouter from './routes/misc'; +import db from './db/store'; const app = express(); const port = 9090; @@ -20,6 +21,12 @@ app.use(cookieParser()); // Playwright webServer health check app.get('/', (_req, res) => res.status(200).send('ok')); +// E2E 테스트 격리용 db 리셋 (테스트 환경 전용) +app.post('/api/test/reset', (_req, res) => { + db.reset(); + res.json({ ok: true }); +}); + // API 라우트 app.use('/api', authRouter); app.use('/api', tripRouter); diff --git a/src/mocks/routes/misc.ts b/src/mocks/routes/misc.ts index 993e209d..39e67957 100644 --- a/src/mocks/routes/misc.ts +++ b/src/mocks/routes/misc.ts @@ -96,11 +96,21 @@ router.delete('/enrollment/:enrollmentNumber', (req: Request, res: Response) => // GET /api/travel/:travelNumber/enrollments router.get('/travel/:travelNumber/enrollments', (req: Request, res: Response) => { const travelNumber = parseInt(req.params.travelNumber); - const enrollments = db.getEnrollmentsByTravel(travelNumber).map((e) => { - const u = db.users.get(e.userNumber); - return { ...e, userName: u?.name || '', userProfileImage: u?.profileImageUrl || null }; - }); - return ok(res, enrollments); + const enrollments = db.getEnrollmentsByTravel(travelNumber) + .filter((e) => e.status === 'PENDING') + .map((e) => { + const u = db.users.get(e.userNumber); + return { + enrollmentNumber: e.enrollmentNumber, + userName: u?.name || '', + userAgeGroup: u?.ageGroup || '', + enrolledAt: e.createdAt, + message: '', + status: e.status, + profileUrl: u?.profileImageUrl || null, + }; + }); + return ok(res, { enrollments, totalCount: enrollments.length }); }); // GET /api/travel/:travelNumber/enrollments/last-viewed @@ -236,7 +246,10 @@ router.get('/notifications', (req: Request, res: Response) => { const size = parseInt(req.query.size as string) || 10; const notifications = [...db.notifications.values()].filter((n) => n.userNumber === user.userNumber); const paginated = notifications.slice(page * size, (page + 1) * size); - return ok(res, { content: paginated, totalElements: notifications.length, number: page, size }); + return ok(res, { + content: paginated, + page: { size, number: page, totalElements: notifications.length, totalPages: Math.ceil(notifications.length / size) || 1 }, + }); }); // ── MyPage (Profile) ────────────────────────────────────────────────────── diff --git a/src/mocks/routes/trip.ts b/src/mocks/routes/trip.ts index 85bba161..055778e5 100644 --- a/src/mocks/routes/trip.ts +++ b/src/mocks/routes/trip.ts @@ -14,36 +14,57 @@ const getBearerToken = (req: Request) => { return auth?.startsWith('Bearer ') ? auth.slice(7) : null; }; +const GENDER_TYPE_KO: Record<string, string> = { + ANY: '모두', + MALE: '남자만', + FEMALE: '여자만', +}; + const formatTrip = (trip: Trip, userNumber?: number) => { const host = db.users.get(trip.userNumber); - const companions = db.getEnrollmentsByTravel(trip.travelNumber) - .filter((e) => e.status === 'ACCEPTED') - .map((e) => { - const u = db.users.get(e.userNumber); - return { userNumber: e.userNumber, name: u?.name || '', profileImageUrl: u?.profileImageUrl || null }; - }); + const acceptedEnrollments = db.getEnrollmentsByTravel(trip.travelNumber) + .filter((e) => e.status === 'ACCEPTED'); + const enrollCount = db.getEnrollmentsByTravel(trip.travelNumber).length; + const bookmarkCount = [...db.bookmarks.values()] + .filter((b) => b.travelNumber === trip.travelNumber).length; + + let loginMemberRelatedInfo = null; + if (userNumber) { + const isHost = trip.userNumber === userNumber; + const enrollment = [...db.enrollments.values()].find( + (e) => e.travelNumber === trip.travelNumber && e.userNumber === userNumber, + ); + loginMemberRelatedInfo = { + hostUser: isHost, + enrollmentNumber: enrollment?.enrollmentNumber ?? null, + bookmarked: db.bookmarks.has(db.getBookmarkKey(userNumber, trip.travelNumber)), + }; + } return { travelNumber: trip.travelNumber, userNumber: trip.userNumber, - hostName: host?.name || '', - hostAgeGroup: host?.ageGroup || '', + userName: host?.name || '', + userAgeGroup: host?.ageGroup || '', + profileUrl: host?.profileImageUrl || null, title: trip.title, details: trip.details, - locationName: trip.locationName, + location: trip.locationName, startDate: trip.startDate, endDate: trip.endDate, + registerDue: trip.endDate, maxPerson: trip.maxPerson, - currentPerson: companions.length + 1, - genderType: trip.genderType, + nowPerson: acceptedEnrollments.length + 1, + genderType: GENDER_TYPE_KO[trip.genderType] ?? trip.genderType, periodType: trip.periodType, - status: trip.status, + postStatus: trip.status, tags: trip.tags, viewCount: trip.viewCount, + enrollCount, + bookmarkCount, createdAt: trip.createdAt, - isBookmarked: userNumber - ? db.bookmarks.has(db.getBookmarkKey(userNumber, trip.travelNumber)) - : false, + bookmarked: loginMemberRelatedInfo?.bookmarked ?? false, + loginMemberRelatedInfo, }; }; @@ -105,11 +126,12 @@ router.get('/travels/recent', (req: Request, res: Response) => { const paginated = active.slice(page * size, (page + 1) * size); return ok(res, { content: paginated.map((t) => formatTrip(t, user?.userNumber)), - totalElements: active.length, - totalPages: Math.ceil(active.length / size), - number: page, - size, - last: (page + 1) * size >= active.length, + page: { + size, + number: page, + totalElements: active.length, + totalPages: Math.ceil(active.length / size), + }, }); }); @@ -128,11 +150,12 @@ router.get('/travels/recommend', (req: Request, res: Response) => { const paginated = active.slice(page * size, (page + 1) * size); return ok(res, { content: paginated.map((t) => formatTrip(t, user?.userNumber)), - totalElements: active.length, - totalPages: Math.ceil(active.length / size), - number: page, - size, - last: (page + 1) * size >= active.length, + page: { + size, + number: page, + totalElements: active.length, + totalPages: Math.ceil(active.length / size), + }, }); }); @@ -197,11 +220,12 @@ router.get('/travels/search', (req: Request, res: Response) => { const paginated = results.slice(page * size, (page + 1) * size); return ok(res, { content: paginated.map((t) => formatTrip(t, user?.userNumber)), - totalElements: results.length, - totalPages: Math.ceil(results.length / size), - number: page, - size, - last: (page + 1) * size >= results.length, + page: { + size, + number: page, + totalElements: results.length, + totalPages: Math.ceil(results.length / size), + }, }); }); @@ -227,10 +251,16 @@ router.get('/travel/:travelNumber/companions', (req: Request, res: Response) => .filter((e) => e.status === 'ACCEPTED') .map((e) => { const u = db.users.get(e.userNumber); - return { userNumber: e.userNumber, name: u?.name || '', profileImageUrl: u?.profileImageUrl || null }; + return { userNumber: e.userNumber, userName: u?.name || '', profileImageUrl: u?.profileImageUrl || null }; }); - return ok(res, companions); + return ok(res, { companions }); +}); + +// ── 여행 계획 ──────────────────────────────────────────────────────────── +// GET /api/travel/:travelNumber/plans +router.get('/travel/:travelNumber/plans', (req: Request, res: Response) => { + return ok(res, { plans: [], nextCursor: null }); }); // ── 참가 신청 수 ────────────────────────────────────────────────────────── diff --git a/src/page-views/auth/OauthGoogle.tsx b/src/page-views/auth/OauthGoogle.tsx index dfc7f840..85ae7b34 100644 --- a/src/page-views/auth/OauthGoogle.tsx +++ b/src/page-views/auth/OauthGoogle.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { getToken } from "@/entities/user"; +import { getToken, OAuthTokenResponse } from "@/entities/user"; import { userStore } from "@/store/client/userStore"; import { useAuth } from "@/features/auth"; import WarningToast from "@/shared/ui/toast/WarningToast"; @@ -27,16 +27,16 @@ const OauthGoogle = () => { useEffect(() => { if (code && state) { getToken("google", code, state) - .then((user: any) => { + .then((user: OAuthTokenResponse | null | undefined) => { if (user?.userStatus === "PENDING" && user?.userNumber && user?.userName) { setTempName(user.userName); - setSocialLogin("google", Number(user.userNumber) as number); + setSocialLogin("google", user.userNumber!); router.push("/registerAge"); } else if (user?.userStatus === "ABLE") { socialLogin({ - socialLoginId: user?.socialLoginId as string, - email: user?.userEmail as string, + socialLoginId: user.socialLoginId!, + email: user.userEmail!, }); } else { showErrorToast("소셜 로그인 과정에서 문제가 발생했습니다.", "/login"); diff --git a/src/page-views/auth/OauthKakao.tsx b/src/page-views/auth/OauthKakao.tsx index 62f25140..e6b71998 100644 --- a/src/page-views/auth/OauthKakao.tsx +++ b/src/page-views/auth/OauthKakao.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import { getToken } from "@/entities/user"; +import { getToken, OAuthTokenResponse } from "@/entities/user"; import { userStore } from "@/store/client/userStore"; import { useAuth } from "@/features/auth"; import { useRouter, useSearchParams } from "next/navigation"; @@ -26,15 +26,15 @@ const OauthKakao = () => { useEffect(() => { if (code && state) { getToken("kakao", code, state) - .then((user: any) => { + .then((user: OAuthTokenResponse | null | undefined) => { if (user?.userStatus === "PENDING" && user?.userNumber && user?.userName) { setTempName(user.userName); - setSocialLogin("kakao", Number(user.userNumber) as number); + setSocialLogin("kakao", user.userNumber); router.push("/registerEmail"); } else if (user?.userStatus === "ABLE") { socialLogin({ - socialLoginId: user?.socialLoginId as string, - email: user?.userEmail as string, + socialLoginId: user.socialLoginId!, + email: user.userEmail!, }); } else { showErrorToast("소셜 로그인 과정에서 문제가 발생했습니다.", "/login"); diff --git a/src/page-views/auth/OauthNaver.tsx b/src/page-views/auth/OauthNaver.tsx index 49a5da8c..223b3f1e 100644 --- a/src/page-views/auth/OauthNaver.tsx +++ b/src/page-views/auth/OauthNaver.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; -import { getToken } from "@/entities/user"; +import { getToken, OAuthTokenResponse } from "@/entities/user"; import { userStore } from "@/store/client/userStore"; import { useAuth } from "@/features/auth"; import { useRouter, useSearchParams } from "next/navigation"; @@ -29,11 +29,11 @@ const OauthNaver = () => { // 네이버 인증 코드를 이용해 서버에서 토큰을 요청 getToken("naver", code, state) - .then((user: any) => { + .then((user: OAuthTokenResponse | null | undefined) => { if (user?.userStatus === "ABLE") { socialLogin({ - socialLoginId: user?.socialLoginId as string, - email: user?.email as string, + socialLoginId: user.socialLoginId!, + email: user.email!, }); } else { showErrorToast("소셜 로그인 과정에서 문제가 발생했습니다.", "/login"); diff --git a/src/page-views/auth/RegisterDone.tsx b/src/page-views/auth/RegisterDone.tsx index e369f618..cc71245c 100644 --- a/src/page-views/auth/RegisterDone.tsx +++ b/src/page-views/auth/RegisterDone.tsx @@ -14,7 +14,7 @@ export default function RegisterDone() { setTimeout(() => { reset(); setSocialLogin(null, null); - router.replace("/login"); // refresh 토큰 받을려면 로그인으로 접속해야함. // qa 요청 들어옴 백엔드와 협의 필요 + router.replace("/"); // 회원가입 완료 → 이미 로그인 상태(accessToken set in authStore), 홈으로 이동 }, 2000); }, []); return ( diff --git a/src/page-views/auth/RegisterTripStyle.tsx b/src/page-views/auth/RegisterTripStyle.tsx index 3c544ed0..3e21a33c 100644 --- a/src/page-views/auth/RegisterTripStyle.tsx +++ b/src/page-views/auth/RegisterTripStyle.tsx @@ -9,6 +9,7 @@ import { IRegisterGoogle, IRegisterKakao } from "@/model/auth"; import { useRouter } from "next/navigation"; import SearchFilterTag from "@/components/designSystem/tag/SearchFilterTag"; import RegisterThirdStepIcon from "@/components/icons/step/register/RegisterThirdStepIcon"; +import WarningToast from "@/shared/ui/toast/WarningToast"; const TAGCOUNT = 18; const categoryButtonTextArray = [ @@ -151,12 +152,14 @@ const RegisterTripStyle = () => { resetGender(); } if (isSocialError) { - alert(isSocialError); + setErrorToast(true); setSocialLogin(null, null); - router.push("/login"); + setTimeout(() => router.push("/login"), 1500); } }, [isSocialError, isSocialSuccess]); + const [errorToast, setErrorToast] = useState(false); + // 버튼 활성화상태. const [activeStates, setActiveStates] = useState<boolean[]>( new Array(TAGCOUNT).fill(false) @@ -288,6 +291,11 @@ const RegisterTripStyle = () => { }} /> </div> + <WarningToast + isShow={errorToast} + setIsShow={setErrorToast} + text="소셜 로그인 과정에서 문제가 발생했습니다." + /> </div> ); }; diff --git a/src/page-views/trip/TripDetailHeader.tsx b/src/page-views/trip/TripDetailHeader.tsx index 6468f313..5c013b8e 100644 --- a/src/page-views/trip/TripDetailHeader.tsx +++ b/src/page-views/trip/TripDetailHeader.tsx @@ -20,7 +20,7 @@ import React, { useEffect, useState } from "react"; // 헤더부터 주최자인지에 따라 화면이 달라서, 헤더에서 여행 정보를 들고 오기. export default function TripDetailHeader() { - const { userId, accessToken } = authStore(); + const { userId, accessToken, isGuestUser: isGuestUserStore } = authStore(); const params = useParams(); const pathname = usePathname(); const travelNumber = params?.travelNumber as string; @@ -98,14 +98,21 @@ export default function TripDetailHeader() { // month, // day, // }; - if (!loginMemberRelatedInfo) { - addHostUserCheck(false); - addEnrollmentNumber(null); - addBookmarked(false); - } else { - addHostUserCheck(loginMemberRelatedInfo.hostUser); - addEnrollmentNumber(loginMemberRelatedInfo.enrollmentNumber); - addBookmarked(loginMemberRelatedInfo.bookmarked); + // 서버 프리페치는 null 토큰으로 실행 → loginMemberRelatedInfo: null + // AppShell이 토큰을 복구하기 전에 이 값으로 hostUserCheck를 덮어쓰면 + // 호스트도 "참가 신청 하기" 버튼이 표시되는 문제 발생. + // accessToken이 확정되거나 명시적 게스트일 때만 인증 관련 필드를 업데이트. + const isAuthResolved = !!accessToken || isGuestUserStore; + if (isAuthResolved) { + if (!loginMemberRelatedInfo) { + addHostUserCheck(false); + addEnrollmentNumber(null); + addBookmarked(false); + } else { + addHostUserCheck(loginMemberRelatedInfo.hostUser); + addEnrollmentNumber(loginMemberRelatedInfo.enrollmentNumber); + addBookmarked(loginMemberRelatedInfo.bookmarked); + } } addProfileUrl(profileUrl); addTravelNumber(travelNumber); @@ -199,22 +206,30 @@ export default function TripDetailHeader() { className="flex items-center justify-around" style={{ display: isTripDetailEdit ? "none" : "flex", - width: hostUserCheck ? "136px" : "auto", + width: "auto", }} > {!isGuestUser() && ( - <div className="w-12 h-12 flex items-center justify-center" onClick={handleNotification}> + <button + type="button" + aria-label="알림" + className="w-12 h-12 flex items-center justify-center" + onClick={handleNotification} + > <AlarmIcon size={23} stroke="var(--color-text-base)" /> - </div> + </button> )} - <div className="w-12 h-12 flex items-center justify-center"> - <ShareIcon /> - </div> + <ShareIcon className="w-12 h-12 flex items-center justify-center" /> {!isGuestUser() && ( - <div className="w-12 h-12 flex items-center justify-center" onClick={onClickThreeDots}> + <button + type="button" + aria-label={hostUserCheck ? "여행 수정/삭제" : "더 보기"} + className="w-12 h-12 flex items-center justify-center" + onClick={onClickThreeDots} + > <MoreIcon /> - </div> + </button> )} <EditAndDeleteModal diff --git a/src/page-views/trip/TripDetailPage.tsx b/src/page-views/trip/TripDetailPage.tsx index 6ecbb4d6..37223679 100644 --- a/src/page-views/trip/TripDetailPage.tsx +++ b/src/page-views/trip/TripDetailPage.tsx @@ -77,7 +77,7 @@ export default function TripDetail() { const [noticeModal, setNoticeModal] = useState(false); const [isAccepted, setIsAccepted] = useState(false); - const { userId, accessToken } = authStore(); + const { userId, accessToken, isGuestUser: isGuestUserStore } = authStore(); const { gender } = myPageStore(); const [isCommentUpdated, setIsCommentUpdated] = useState(false); const [isKakaoMapLoad, setIsKakaooMapLoad] = useState(false); @@ -126,10 +126,9 @@ export default function TripDetail() { // const isClosed = !Boolean(daysLeft(`${dueDate.year}-${dueDate.month}-${dueDate.day}`) > 0) || maxPerson === nowPerson; const isClosed = false; const { cancel, cancelMutation } = useEnrollment(parseInt(travelNumber)); - const { tripEnrollmentCount } = useTripDetail(parseInt(travelNumber)); + const { tripEnrollmentCount, companions } = useTripDetail(parseInt(travelNumber)); const nowEnrollmentCount = tripEnrollmentCount.data as any; const { editToastShow, setEditToastShow } = editStore(); - const { companions } = useTripDetail(parseInt(travelNumber)); const allCompanions = (companions as any)?.data?.companions; const alreadyApplied = !!enrollmentNumber; const [ref, inView] = useInView(); @@ -138,7 +137,8 @@ export default function TripDetail() { queryFn: ({ pageParam }) => { return getPlans(Number(travelNumber), pageParam) as any; }, - staleTime: 0, + staleTime: 5 * 60 * 1000, + enabled: !!travelNumber, initialPageParam: 0, getNextPageParam: (lastPage) => { if (!lastPage?.nextCursor) { @@ -361,6 +361,9 @@ export default function TripDetail() { <div ref={containerRef} + role="region" + tabIndex={0} + aria-label="여행 상세 내용" className="px-6 overflow-y-auto relative h-[calc(100svh-116px)] no-scrollbar overscroll-none pb-[104px]" > <TopModal @@ -384,7 +387,7 @@ export default function TripDetail() { {/* 제목 */} <div className="mt-8 text-xl font-semibold text-left">{title}</div> {/* 내용 */} - <div ref={detailRef} className="mt-4 text-base max-h-[100px] overflow-y-auto whitespace-pre-line font-normal leading-[22.4px] text-left text-[var(--color-text-base)]">{details}</div> + <div ref={detailRef} tabIndex={0} aria-label="여행 상세 설명" className="mt-4 text-base max-h-[100px] overflow-y-auto whitespace-pre-line font-normal leading-[22.4px] text-left text-[var(--color-text-base)]">{details}</div> {/*태그 */} <div className="mt-8 flex flex-wrap gap-2"> {tags.map((tag, idx) => ( @@ -442,7 +445,11 @@ export default function TripDetail() { </div> <div className="bg-[#e7e7e7] w-full h-[1px]" /> - <div className="py-[11px] pl-2 cursor-pointer flex items-center justify-between" onClick={companionsViewHandler}> + <button + type="button" + className="py-[11px] pl-2 w-full flex items-center justify-between" + onClick={companionsViewHandler} + > <div className="flex items-center"> <div className="flex items-center w-[100px] gap-2 mr-3"> {genderType === "모두" ? ( @@ -461,7 +468,7 @@ export default function TripDetail() { <div className="flex items-center justify-center w-12 h-12"> <ArrowIcon /> </div> - </div> + </button> </div> </TopModal> <div @@ -509,54 +516,63 @@ export default function TripDetail() { <Spacing size={120} /> <ButtonContainer backgroundColor="var(--color-search-bg)"> - <ApplyListButton - hostUserCheck={hostUserCheck} - nowEnrollmentCount={nowEnrollmentCount} - bookmarkOnClick={bookmarkClickHandler} - bookmarked={bookmarked} - onClick={buttonClickHandler} - disabled={ - (hostUserCheck && nowEnrollmentCount === 0) || - (!hostUserCheck && !verifyGenderType(genderType, gender)) || - isAccepted || - isClosed - } - addStyle={{ - backgroundColor: isClosed - ? "var(--color-muted3)" - : !verifyGenderType(genderType, gender) || isAccepted + {/* auth 복구 대기 중 (서버 프리페치 null토큰 → AppShell 토큰 복구 전) 스켈레톤 */} + {!(!!accessToken || isGuestUserStore) ? ( + <div + className="h-[54px] w-full rounded-[12px] bg-[var(--color-muted3)] animate-pulse" + aria-hidden="true" + /> + ) : ( + <ApplyListButton + hostUserCheck={hostUserCheck} + nowEnrollmentCount={nowEnrollmentCount} + bookmarkOnClick={bookmarkClickHandler} + bookmarked={bookmarked} + onClick={buttonClickHandler} + disabled={ + (hostUserCheck && nowEnrollmentCount === 0) || + (!hostUserCheck && !verifyGenderType(genderType, gender)) || + isAccepted || + isClosed + } + addStyle={{ + backgroundColor: isClosed ? "var(--color-muted3)" - : hostUserCheck - ? nowEnrollmentCount > 0 - ? "var(--color-keycolor)" - : "var(--color-muted3)" - : "var(--color-keycolor)", - color: isClosed - ? "var(--color-muted4)" - : !verifyGenderType(genderType, gender) - ? "var(--color-text-muted)" - : hostUserCheck - ? nowEnrollmentCount > 0 - ? "var(--color-muted4)" - : "var(--color-text-muted)" - : "var(--color-muted4)", - }} - text={ - hostUserCheck - ? "참가 신청 목록" - : isAccepted - ? "참가 중인 여행" - : alreadyApplied - ? "참가 신청 취소" - : "참가 신청 하기" - } - ></ApplyListButton> + : !verifyGenderType(genderType, gender) || isAccepted + ? "var(--color-muted3)" + : hostUserCheck + ? nowEnrollmentCount > 0 + ? "var(--color-keycolor)" + : "var(--color-muted3)" + : "var(--color-keycolor)", + color: isClosed + ? "var(--color-muted4)" + : !verifyGenderType(genderType, gender) + ? "var(--color-text-muted)" + : hostUserCheck + ? nowEnrollmentCount > 0 + ? "var(--color-muted4)" + : "var(--color-text-muted)" + : "var(--color-muted4)", + }} + text={ + hostUserCheck + ? "참가 신청 목록" + : isAccepted + ? "참가 중인 여행" + : alreadyApplied + ? "참가 신청 취소" + : "참가 신청 하기" + } + /> + )} </ButtonContainer> <CompanionsView isOpen={personViewClicked} setIsOpen={setPersonViewClicked} /> <div className="h-svh w-full pointer-events-none fixed top-0 min-[440px]:w-[390px] min-[440px]:left-1/2 min-[440px]:-translate-x-1/2 z-[1000]"> <button type="button" + aria-label={isCommentUpdated ? "새 댓글 보기" : "댓글 보기"} className="absolute pointer-events-auto right-6 bottom-[124px] w-[70px] h-[70px] rounded-full flex justify-center items-center text-white bg-[var(--color-text-base)] z-[1000] text-[32px]" onClick={commentClickHandler} > diff --git a/src/page-views/trip/TripListPage.tsx b/src/page-views/trip/TripListPage.tsx index c13baefd..88d9a77d 100644 --- a/src/page-views/trip/TripListPage.tsx +++ b/src/page-views/trip/TripListPage.tsx @@ -94,9 +94,9 @@ const TripList = () => { </div> {!isGuestUser() && ( - <div className="cursor-pointer w-12 flex items-center justify-center" onClick={handleNotification}> + <button type="button" className="w-12 flex items-center justify-center" onClick={handleNotification} aria-label="알림"> <AlarmIcon /> - </div> + </button> )} </div> <Spacing size={8} /> @@ -110,7 +110,7 @@ const TripList = () => { sort={sort} > <div className="text-sm font-medium leading-[16.71px] tracking-[-0.025em]"> - 총 <span className="text-[#3e8d00] font-bold">{data?.pages[0].page.totalElements ?? 0}건</span> + 총 <span className="text-[var(--color-keycolor)] font-bold">{data?.pages[0].page.totalElements ?? 0}건</span> </div> </SortHeader> </div> diff --git a/src/shared/ui/button/ApplyListButton.tsx b/src/shared/ui/button/ApplyListButton.tsx index cb1a2a94..c7d8b71b 100644 --- a/src/shared/ui/button/ApplyListButton.tsx +++ b/src/shared/ui/button/ApplyListButton.tsx @@ -41,6 +41,7 @@ const ApplyListButton = ({ {!hostUserCheck && ( <button type="button" + aria-label={bookmarked ? '즐겨찾기 해제' : '즐겨찾기 추가'} onClick={bookmarkOnClick} className="border-none bg-transparent" > diff --git a/src/shared/ui/input/ValidationInputField.tsx b/src/shared/ui/input/ValidationInputField.tsx index 04277412..59c2143f 100644 --- a/src/shared/ui/input/ValidationInputField.tsx +++ b/src/shared/ui/input/ValidationInputField.tsx @@ -2,12 +2,13 @@ import StateInputField from './StateInputField'; import InfoText from '@/shared/ui/text/InfoText'; -import React from 'react'; +import React, { forwardRef } from 'react'; interface ValidationInputFieldProps { type: string; name: string; onChange: React.ChangeEventHandler<HTMLInputElement>; + onBlur?: React.FocusEventHandler<HTMLInputElement>; shake?: boolean; value: string; hasError?: boolean; @@ -21,27 +22,37 @@ interface ValidationInputFieldProps { /** * StateInputField + InfoText를 조합한 유효성 검증 입력 필드. * hasError/showSuccess+success 상태에 따라 메시지를 표시한다. + * + * forwardRef: react-hook-form register()가 반환하는 ref를 StateInputField → <input>까지 + * 전달해 RHF 필드 등록이 정상 동작하도록 한다. */ -export default function ValidationInputField({ - type, - name, - onChange, - value, - handleRemoveValue, - hasError, - success, - showSuccess = false, - placeholder, - shake, - message, -}: ValidationInputFieldProps) { +const ValidationInputField = forwardRef<HTMLInputElement, ValidationInputFieldProps>( + function ValidationInputField( + { + type, + name, + onChange, + onBlur, + value, + handleRemoveValue, + hasError, + success, + showSuccess = false, + placeholder, + shake, + message, + }, + ref + ) { return ( <> <StateInputField + ref={ref} handleRemoveValue={handleRemoveValue} type={type} name={name} onChange={onChange} + onBlur={onBlur} value={value} hasError={hasError} success={success} @@ -60,4 +71,9 @@ export default function ValidationInputField({ </div> </> ); -} + } +); + +ValidationInputField.displayName = 'ValidationInputField'; + +export default ValidationInputField; diff --git a/src/shared/ui/layout/Header.tsx b/src/shared/ui/layout/Header.tsx index 6485f535..8834176c 100644 --- a/src/shared/ui/layout/Header.tsx +++ b/src/shared/ui/layout/Header.tsx @@ -53,7 +53,7 @@ const Header = () => { > {!shouldShowAlarmIcon() && ( <div className="flex items-center"> - <button type="button" className="cursor-pointer" onClick={handleBack}> + <button type="button" className="cursor-pointer" aria-label="뒤로 가기" onClick={handleBack}> <BackIcon /> </button> {(checkRoute.startsWith(ROUTES.TRIP.DETAIL) || diff --git a/src/shared/ui/profile/RoundedImage.test.tsx b/src/shared/ui/profile/RoundedImage.test.tsx index 68257fda..fffab097 100644 --- a/src/shared/ui/profile/RoundedImage.test.tsx +++ b/src/shared/ui/profile/RoundedImage.test.tsx @@ -4,23 +4,31 @@ import { axe } from 'jest-axe'; import RoundedImage from './RoundedImage'; describe('RoundedImage', () => { - it('지정한 size로 width/height를 렌더링한다', () => { + it('src가 있으면 img 태그를 렌더링한다', () => { const { container } = render(<RoundedImage size={48} src="https://example.com/img.jpg" />); - const el = container.firstChild as HTMLElement; - expect(el.style.width).toBe('48px'); - expect(el.style.height).toBe('48px'); + const img = container.querySelector('img'); + expect(img).not.toBeNull(); }); - it('src가 있으면 background-image를 설정한다', () => { + it('img에 width/height 속성이 설정된다', () => { const { container } = render(<RoundedImage size={48} src="https://example.com/img.jpg" />); - const el = container.firstChild as HTMLElement; - expect(el.style.backgroundImage).toContain('example.com/img.jpg'); + const img = container.querySelector('img'); + expect(img?.getAttribute('width')).toBe('48'); + expect(img?.getAttribute('height')).toBe('48'); }); - it('src가 빈 문자열이면 회색 배경을 설정한다', () => { + it('src가 빈 문자열이면 회색 배경 div를 렌더링한다', () => { const { container } = render(<RoundedImage size={48} src="" />); - const el = container.firstChild as HTMLElement; - expect(el.style.backgroundColor).toBe('rgba(217, 217, 217, 1)'); + const div = container.firstChild as HTMLElement; + expect(div.tagName).toBe('DIV'); + expect(div.style.backgroundColor).toBe('rgba(217, 217, 217, 1)'); + }); + + it('src가 빈 문자열이면 지정한 size로 width/height를 설정한다', () => { + const { container } = render(<RoundedImage size={48} src="" />); + const div = container.firstChild as HTMLElement; + expect(div.style.width).toBe('48px'); + expect(div.style.height).toBe('48px'); }); it('rounded-full 클래스를 가진다', () => { @@ -29,7 +37,7 @@ describe('RoundedImage', () => { }); it('접근성 위반이 없어야 한다', async () => { - const { container } = render(<RoundedImage size={48} src="https://example.com/img.jpg" />); + const { container } = render(<RoundedImage size={48} src="https://example.com/img.jpg" alt="프로필" />); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/shared/ui/profile/RoundedImage.tsx b/src/shared/ui/profile/RoundedImage.tsx index 21772340..a17cc6e5 100644 --- a/src/shared/ui/profile/RoundedImage.tsx +++ b/src/shared/ui/profile/RoundedImage.tsx @@ -1,8 +1,11 @@ 'use client'; +import Image from 'next/image'; + interface RoundedImageProps { size: number; src: string; + alt?: string; } /** @@ -10,19 +13,30 @@ interface RoundedImageProps { * src가 빈 문자열이면 회색 배경(muted3) 표시. * * Refactoring notes: - * - Emotion styled.div → Tailwind + inline style (동적 size/src) - * - NOTE (Phase 1.5): div + background-image → img 태그 전환 시 접근성 개선 가능 + * - Phase 1: Emotion styled.div → Tailwind + inline style + * - Phase 6: div + background-image → next/image (WebP 자동 변환, lazy loading, CLS 방지) */ -const RoundedImage = ({ size, src }: RoundedImageProps) => { +const RoundedImage = ({ size, src, alt = '' }: RoundedImageProps) => { + if (!src) { + return ( + <div + className="rounded-full flex-shrink-0" + style={{ + width: `${size}px`, + height: `${size}px`, + backgroundColor: 'rgba(217, 217, 217, 1)', + }} + /> + ); + } + return ( - <div - className="rounded-full bg-cover" - style={{ - width: `${size}px`, - height: `${size}px`, - backgroundImage: src ? `url(${src})` : undefined, - backgroundColor: src === '' ? 'rgba(217, 217, 217, 1)' : undefined, - }} + <Image + src={src} + alt={alt} + width={size} + height={size} + className="rounded-full object-cover flex-shrink-0" /> ); }; diff --git a/src/shared/ui/tag/BoxLayoutTag.tsx b/src/shared/ui/tag/BoxLayoutTag.tsx index 4f44a1e7..42eaeb0d 100644 --- a/src/shared/ui/tag/BoxLayoutTag.tsx +++ b/src/shared/ui/tag/BoxLayoutTag.tsx @@ -27,7 +27,7 @@ const SIZE_STYLES: Record<'small' | 'medium' | 'large', React.CSSProperties> = { const DEFAULT_STYLE: React.CSSProperties = { backgroundColor: 'rgba(240, 240, 240, 1)', padding: '4px 10px', - color: 'rgba(132, 132, 132, 1)', + color: 'var(--color-text-muted)', borderRadius: '20px', fontSize: '12px', fontWeight: '400', diff --git a/src/shared/ui/text/InfoText.tsx b/src/shared/ui/text/InfoText.tsx index af0ead12..9f11533a 100644 --- a/src/shared/ui/text/InfoText.tsx +++ b/src/shared/ui/text/InfoText.tsx @@ -34,7 +34,7 @@ const InfoText = ({ children, shake = false, }: InfoTextProps) => { - const color = hasError ? '#ED1E1E' : success ? '#5DB21B' : '#ABABAB'; + const color = hasError ? '#ED1E1E' : success ? '#5DB21B' : 'var(--color-text-muted2)'; return ( <div diff --git a/src/test/mocks/next-view-transitions.tsx b/src/test/mocks/next-view-transitions.tsx new file mode 100644 index 00000000..979412a2 --- /dev/null +++ b/src/test/mocks/next-view-transitions.tsx @@ -0,0 +1,21 @@ +/** + * next-view-transitions Vitest mock + * + * next-view-transitions가 next/link를 확장자 없이 import해서 + * Vitest ESM 리졸버가 실패함. 단위 테스트에서는 View Transition 동작 자체가 + * 필요 없으므로 최소 인터페이스만 제공. + */ +import React from 'react'; + +export const ViewTransitions = ({ children }: { children: React.ReactNode }) => ( + <>{children}</> +); + +export const useTransitionRouter = () => ({ + push: (_url: string) => {}, + replace: (_url: string) => {}, + back: () => {}, + forward: () => {}, + refresh: () => {}, + prefetch: () => {}, +}); diff --git a/src/widgets/home/CreateTripButton.tsx b/src/widgets/home/CreateTripButton.tsx index 2d7a0f02..b787d2f3 100644 --- a/src/widgets/home/CreateTripButton.tsx +++ b/src/widgets/home/CreateTripButton.tsx @@ -121,6 +121,8 @@ export default function CreateTripButton({ type="button" ref={addRef} onClick={toggleRotation} + aria-label={isClicked ? "메뉴 닫기" : `${type === "community" ? "글쓰기" : "여행 만들기"} 메뉴 열기`} + aria-expanded={isClicked} className="absolute pointer-events-auto bottom-[124px] cursor-pointer w-[70px] h-[70px] rounded-full flex justify-center items-center text-white bg-[var(--color-text-base)] z-[1003] text-[32px] transition-transform duration-300" style={{ right: buttonRight, diff --git a/src/widgets/home/Navbar.tsx b/src/widgets/home/Navbar.tsx index fb6a9bce..c5a257ad 100644 --- a/src/widgets/home/Navbar.tsx +++ b/src/widgets/home/Navbar.tsx @@ -67,7 +67,7 @@ const Navbar = () => { const Icon = icons[idx]; const isLinkActive = getIsActive(page); const activeColor = "var(--color-text-base)"; - const inactiveColor = "var(--color-muted3)"; + const inactiveColor = "var(--color-text-muted)"; const iconProps = { stroke: isLinkActive ? activeColor : inactiveColor, fill: isLinkActive ? activeColor : "none", diff --git a/vitest.config.mts b/vitest.config.mts index eb55e735..e3269faa 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -25,6 +25,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@components': path.resolve(__dirname, './src/components'), + 'next-view-transitions': path.resolve(__dirname, './src/test/mocks/next-view-transitions.tsx'), }, }, });