diff --git a/docs/features/banner-curation-v2-links/design.md b/docs/features/banner-curation-v2-links/design.md new file mode 100644 index 0000000..98c8da9 --- /dev/null +++ b/docs/features/banner-curation-v2-links/design.md @@ -0,0 +1,66 @@ +# Banner Curation V2 Links Design + +## UI Summary + +기존 배너 등록/수정 화면의 `링크 설정` 카드를 그대로 사용한다. 큐레이션 타입을 선택했을 때만 달라지는 동작은 자동 생성되는 읽기 전용 이동 URL의 형식이며, 운영자는 기존과 동일하게 V2 큐레이션 드롭다운에서 대상만 선택한다. 기존 배너 데이터 전환은 운영 화면에 별도 버튼을 추가하지 않는 일회성 개발 환경 마이그레이션으로 처리한다. + +## Navigation + +- Route: + - 배너 관리: `/banners`, `/banners/new`, `/banners/:id` + - 배너가 저장하는 Product 이동 경로: `/curation/:id` +- Sidebar/menu placement: + - 기존 `배너 관리` 메뉴를 유지한다. +- Entry actions: + - 배너 등록 또는 수정 화면의 `링크 설정 > 큐레이션 선택`에서 대상 큐레이션을 선택한다. + +## List View + +- 기존 `BannerListPage`의 목록, 검색, 필터, 정렬순서 UI는 변경하지 않는다. +- 일회성 마이그레이션은 목록에 노출하지 않는다. 데이터 변경 대상과 결과는 개발 작업 로그로 확인한다. + +## Detail/Create/Edit View + +- Sections/cards: + - 기존 `링크 설정` 카드를 유지한다. +- Fields: + - `배너 타입`이 `큐레이션`이면 `큐레이션 선택` 드롭다운을 표시한다. + - 드롭다운 옵션은 활성 상태인 V2 큐레이션의 `name`만 표시한다. + - 대상 선택 시 `이동 URL`은 `/curation/{id}`로 자동 표시하며 읽기 전용으로 둔다. + - 기존 레거시 URL(`/search?curationId={id}`, `/alcohols/search?curationId={id}`)을 가진 배너를 열어도 해당 ID의 큐레이션이 선택 상태로 표시된다. + - 이미 V2 URL(`/curation/{id}`)인 배너도 해당 ID의 큐레이션이 선택 상태로 표시된다. +- Validation messages: + - 큐레이션 타입에서 대상이 없으면 기존 문구 `큐레이션을 선택해주세요`를 유지한다. +- Primary/secondary actions: + - 기존 `등록`·`저장`·`삭제` 동작과 피드백을 유지한다. + +## State and Feedback + +- Loading: + - 기존 큐레이션 목록 로딩 상태와 배너 상세 로딩 상태를 유지한다. +- Empty: + - 선택 가능한 V2 큐레이션이 없으면 드롭다운은 빈 상태로 표시한다. +- Error: + - 기존 배너 저장 실패 처리와 토스트를 유지한다. + - 일회성 마이그레이션에서 안전하게 파싱할 수 없는 링크, 상세 조회 실패, PUT 실패는 ID·사유를 실행 결과에 기록한다. +- Success: + - 마이그레이션이 갱신한 배너 ID와 변경 전후 URL을 실행 결과에 기록한다. +- Destructive confirmation: + - 링크 전환은 개발 환경에서 이미 사용자 승인된 일회성 데이터 정정이므로 별도 운영 UI 확인 다이얼로그는 추가하지 않는다. + +## Design System Usage + +- Components: + - 기존 `Card`, `FormField`, `Select`, `Input`, `Label`, `Checkbox`를 유지한다. +- Tokens/classes: + - 기존 큐레이션 타입의 `bg-muted` 읽기 전용 URL 필드 스타일을 유지한다. +- Existing pages to mirror: + - `src/pages/banners/BannerDetail.tsx` + - `src/pages/banners/components/BannerLinkSettingsCard.tsx` + +## Manual UI Review Points + +- [ ] 큐레이션 타입에서 V2 큐레이션을 선택하면 이동 URL이 `/curation/{id}`로 표시되는지 확인한다. +- [ ] 비활성 V2 큐레이션은 선택 드롭다운에 표시되지 않는지 확인한다. +- [ ] 레거시 및 V2 링크를 가진 기존 큐레이션 배너를 열었을 때 드롭다운이 올바른 큐레이션을 표시하는지 확인한다. +- [ ] 큐레이션 타입이 아닌 배너의 직접 URL 입력 및 외부 URL 선택 동작이 그대로인지 확인한다. diff --git a/docs/features/banner-curation-v2-links/plan.md b/docs/features/banner-curation-v2-links/plan.md new file mode 100644 index 0000000..581c67d --- /dev/null +++ b/docs/features/banner-curation-v2-links/plan.md @@ -0,0 +1,89 @@ +# Banner Curation V2 Links Implementation Plan + +## Summary + +Change curation banner links generated by the admin from legacy V1 search URLs to Product V2 detail URLs, preserve selection state for legacy and V2 links, and add a guarded one-time development migration script that updates existing legacy CURATION banner records through the published per-banner PUT API. + +## Inputs + +- Spec: `docs/features/banner-curation-v2-links/spec.md` +- Design: `docs/features/banner-curation-v2-links/design.md` +- API source: + - Published admin API documentation: `https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html` + - Dev API base from `.env.dev`: `https://admin-api.development.bottle-note.com` + - Workspace issue: `https://github.com/bottle-note/workspace/issues/299` + +## Data and API Mapping + +- `GET /admin/api/v2/curations?isActive=true` + - Existing `useCurationList({ isActive: true })` / `curationService.list` supplies active `CurationV2ListItem[]` to `BannerLinkSettingsCard`. + - No type, service, or hook change is needed; the V2 source conversion already exists. +- `targetUrl` + - `createCurationTargetUrl(curationId)` returns `/curation/{curationId}`. + - `extractCurationIdFromTargetUrl(targetUrl)` returns an ID only for local `/curation/{id}`, `/search?curationId={id}`, or `/alcohols/search?curationId={id}` URLs. + - `BannerLinkSettingsCard` creates the new URL and `useBannerDetailForm` uses the extractor to restore the selected curation. +- `GET /admin/api/v1/banners?bannerType=CURATION&page={n}&size={n}` + - Migration enumerates every CURATION banner ID from all pages. The list response does not contain `targetUrl`. +- `GET /admin/api/v1/banners/{bannerId}` + - Migration reads the current link and full field set for each listed banner. +- `PUT /admin/api/v1/banners/{bannerId}` + - Migration submits the complete existing banner payload, changing only `targetUrl` to `/curation/{id}` and preserving `isExternalUrl: false`. + - `PATCH /admin/api/v1/banners/bulk/reorder` is excluded because it only updates ordering. + +## Implementation Steps + +1. Add `src/pages/banners/banner-curation-link.ts`. + - Export `createCurationTargetUrl(curationId: number): string`. + - Export `extractCurationIdFromTargetUrl(targetUrl: string | null | undefined): number | null`. + - Accept only the three local URL forms defined above; reject external and malformed URLs. +2. Update `BannerLinkSettingsCard`. + - Replace the inline legacy URL string with `createCurationTargetUrl`. + - Keep the existing V2 list item prop type and read-only field behavior. +3. Update `useBannerDetailForm`. + - Replace its local query-string-only parser with `extractCurationIdFromTargetUrl`. + - Correct its URL documentation comment to describe all accepted formats. +4. Update `BannerDetailPage`. + - Request the V2 curation list with `isActive: true` so inactive curations cannot be selected as new banner targets. +5. Add focused unit tests at `src/pages/banners/__tests__/banner-curation-link.test.ts`. + - Cover V2 URL creation. + - Cover V2, current legacy, and historical legacy extraction. + - Cover malformed IDs, external URLs, unrelated routes, and missing URLs. +6. Add `scripts/migrate-curation-banner-links.mjs`. + - Use Node built-in `fetch`; do not import browser-only API clients. + - Read `.env.dev` without emitting credentials and require `VITE_API_BASE_URL`, `VITE_E2E_TEST_ID`, and `VITE_E2E_TEST_PW`. + - Log in through `POST /admin/api/v1/auth/login`, retain the access token only in memory, and send it as a bearer token for migration requests. + - Default to dry-run. `--apply` is the only mode that sends PUT requests. + - Refuse `--apply` unless the base URL is the development admin API host, preventing accidental production updates. + - Enumerate every `bannerType=CURATION` page, fetch each detail, parse only safe legacy candidates, and print JSON results for `updated`, `skipped`, and `failed` entries without printing credentials or tokens. + - On apply, send each complete PUT payload sequentially and verify the response before recording success. Do not update records already using `/curation/{id}`. +7. Run the script once in dry-run mode against development, inspect the candidate IDs and planned URL changes, then run it with `--apply` under the user-approved development scope. +8. Re-run dry-run after apply and require zero remaining legacy candidates. Record updated, skipped, and failed results in the handoff. + +## Edge Cases + +- A CURATION banner with `isExternalUrl=true` is skipped even if its URL contains `curationId`. +- A URL with a missing, duplicate, non-integer, zero, negative, or unrelated `curationId` is skipped and reported. +- Absolute URLs and non-CURATION banners are never rewritten. +- V2 links are not rewritten; they are reported as already migrated. +- If the list pagination changes during migration, the script processes the pages returned at execution time and reports individual failures rather than retrying blind writes. +- A failed PUT does not stop the remaining candidate processing; its ID and error are recorded for follow-up. +- Product-route verification is out of this repository's control and remains a manual check. + +## Verification Checklist + +- [ ] `pnpm test:run src/pages/banners/__tests__/banner-curation-link.test.ts src/pages/banners/__tests__/banner.schema.test.ts` +- [ ] `pnpm test:run src/hooks/__tests__/useCurations.test.ts` +- [ ] `pnpm build` +- [ ] `pnpm exec eslint src/pages/banners/banner-curation-link.ts src/pages/banners/components/BannerLinkSettingsCard.tsx src/pages/banners/useBannerDetailForm.ts scripts/migrate-curation-banner-links.mjs` +- [ ] `node scripts/migrate-curation-banner-links.mjs` reports the expected development candidates without mutation. +- [ ] `node scripts/migrate-curation-banner-links.mjs --apply` completes with no unexpected failures. +- [ ] A final dry-run reports zero legacy migration candidates. +- [ ] Manual UI check: selecting a V2 curation on a banner writes `/curation/{id}` and legacy/V2 banner details restore the correct dropdown selection. + +## Implementation Notes + +- Do not add an admin UI action for this one-time migration. +- Do not change the Product route to `/dashboard/curations/:id`; #299 specifies `/curation/{id}`. +- Do not store or log test credentials, access tokens, refresh tokens, or full authorization headers. +- Do not modify non-CURATION banners or production data. +- Existing inactive curation banner links are preserved when an old banner is opened, but inactive options are not offered for a new selection. diff --git a/docs/features/banner-curation-v2-links/spec.md b/docs/features/banner-curation-v2-links/spec.md new file mode 100644 index 0000000..f2393fe --- /dev/null +++ b/docs/features/banner-curation-v2-links/spec.md @@ -0,0 +1,77 @@ +# Banner Curation V2 Links Spec + +## Summary + +큐레이션 타입 배너가 V1 검색 결과 페이지가 아닌 Product의 큐레이션 V2 상세 화면으로 이동하도록 링크 구조를 전환한다. 새로 생성하거나 수정하는 배너는 V2 큐레이션을 선택해 `/curation/{id}` 링크를 저장한다. 이미 저장된 레거시 큐레이션 배너도 일회성 API 마이그레이션으로 같은 형식으로 갱신한다. + +## Source Inputs + +- API docs/response shape: + - Published admin API documentation: `https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html` + - `GET /admin/api/v1/banners` supports `bannerType`, pagination, and returns banner IDs but not `targetUrl`. + - `GET /admin/api/v1/banners/{bannerId}` returns `targetUrl` and every field required by `PUT /admin/api/v1/banners/{bannerId}`. + - No bulk banner-update endpoint is published. `PATCH /admin/api/v1/banners/bulk/reorder` is ordering-only and must not be used for this migration. + - `GET /admin/api/v2/curations` provides the V2 curation list used by the banner selector. +- Existing UI/code references: + - `src/pages/banners/BannerDetail.tsx`: already loads V2 curations through `useCurationList` from `useCurations`. + - `src/pages/banners/components/BannerLinkSettingsCard.tsx`: currently generates the legacy `/search?curationId={id}` URL. + - `src/pages/banners/useBannerDetailForm.ts`: extracts `curationId` from legacy query-string URLs when editing existing banners. +- User request: + - GitHub workspace issue #299: `https://github.com/bottle-note/workspace/issues/299` + - Existing legacy links should be updated in bulk through the admin API. + +## Admin Workflow + +- 관리자는 배너 등록 또는 수정 화면에서 배너 타입을 `큐레이션`으로 선택한다. +- 관리자는 활성 상태인 V2 큐레이션 목록에서 링크 대상을 선택한다. +- 선택하면 이동 URL은 `/curation/{id}`로 자동 설정되고, 읽기 전용으로 표시된다. +- 기존 큐레이션 타입 배너를 수정 화면에서 열면 레거시 링크와 V2 링크 모두에서 큐레이션 ID를 인식해 선택 상태를 표시한다. +- 일회성 마이그레이션은 큐레이션 타입의 내부 링크 배너만 대상으로 하며, 레거시 링크를 `/curation/{id}`로 갱신한다. + +## Data Requirements + +- New and edited curation-banner link: + - `targetUrl`: `/curation/{curationId}` + - `isExternalUrl`: `false` + - `bannerType`: `CURATION` +- Banner curation selector query: + - `GET /admin/api/v2/curations?isActive=true` + - 비활성 큐레이션은 새 배너의 링크 대상으로 표시하지 않는다. +- Migration candidate requirements: + - `bannerType === 'CURATION'` + - `isExternalUrl === false` + - `targetUrl` is a recognized legacy curation URL with a numeric `curationId`, including `/search?curationId={id}` and the historical `/alcohols/search?curationId={id}` form. +- Migration request: + - Fetch every CURATION banner page through `GET /admin/api/v1/banners`. + - Fetch each candidate detail through `GET /admin/api/v1/banners/{bannerId}`. + - Send `PUT /admin/api/v1/banners/{bannerId}` with the existing required detail fields unchanged except `targetUrl` set to `/curation/{curationId}` and `isExternalUrl` set to `false`. + - Skip and report records whose URL cannot be safely parsed or whose detailed record no longer meets the candidate requirements. + +## Acceptance Criteria + +- [ ] 새 큐레이션 타입 배너를 저장하면 `targetUrl`이 `/curation/{id}` 형식이다. +- [ ] 큐레이션 선택 목록에는 활성 상태인 V2 큐레이션만 표시된다. +- [ ] 수정 화면에서 레거시 및 V2 큐레이션 링크 모두 해당 큐레이션을 선택 상태로 표시한다. +- [ ] 일회성 마이그레이션은 모든 페이지의 후보 배너를 조회하고, 안전하게 파싱 가능한 내부 큐레이션 링크만 갱신한다. +- [ ] 마이그레이션은 배너의 링크 외 필드와 정렬 순서를 변경하지 않는다. +- [ ] 마이그레이션 결과에는 갱신·건너뜀·실패 배너 ID와 사유가 남는다. +- [ ] 배너 클릭을 처리하는 Product 앱에서 `/curation/{id}`가 V2 큐레이션 상세로 연결된다. + +## In Scope + +- 배너 폼의 V2 링크 생성 및 기존 링크 파싱. +- V2 큐레이션 선택 타입을 유지하는 API/타입 연결. +- 개발 환경의 기존 레거시 큐레이션 배너 일회성 API 마이그레이션과 결과 보고. +- 단위 테스트 및 마이그레이션 dry-run 확인. + +## Out of Scope + +- Product 앱의 `/curation/{id}` 라우트 구현 또는 수정. +- API 서버에 전용 bulk banner-update endpoint 추가. +- CURATION 이외 배너 타입의 링크 정책 변경. +- 운영 환경 데이터 변경. 운영 전환은 별도 승인과 대상 검토 후 수행한다. + +## Open Questions + +- Product 앱의 `/curation/{id}` 라우트가 배포 환경에서 이미 V2 상세 화면으로 동작하는지 수동 확인이 필요하다. +- 레거시 링크를 수정할 때만 갱신하는 대신, 이번 요청에 따라 개발 환경에서는 일회성 마이그레이션으로 일괄 갱신한다. diff --git a/e2e/pages/curation-detail.page.ts b/e2e/pages/curation-detail.page.ts deleted file mode 100644 index 803f9f4..0000000 --- a/e2e/pages/curation-detail.page.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { type Page } from '@playwright/test'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { BasePage } from './base.page'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -/** - * 큐레이션 상세 페이지 Page Object - * - * 실제 UI 구조: - * - 기본 정보 카드 (큐레이션명, 설명, 노출순서, 활성상태) - * - 포함된 위스키 카드 (검색 선택, 위스키 목록) - * - 커버 이미지 카드 - */ -export class CurationDetailPage extends BasePage { - constructor(page: Page) { - super(page); - } - - /* ============================================ - * Locators - * ============================================ */ - - /** 페이지 제목 (등록/수정) */ - readonly pageTitle = () => this.page.getByRole('heading', { level: 1 }); - - /** 뒤로가기 버튼 (아이콘 전용 버튼 - 헤더 영역의 첫 번째 버튼) */ - readonly backButton = () => this.page.locator('main button').first(); - - /** 저장/등록 버튼 */ - readonly saveButton = () => this.page.getByRole('button', { name: /등록|저장/ }); - - /** 삭제 버튼 */ - readonly deleteButton = () => this.page.getByRole('button', { name: '삭제' }); - - /** 로딩 상태 */ - readonly loadingState = () => this.page.getByText('로딩 중...'); - - /* Form Fields */ - - /** 큐레이션명 입력 */ - readonly nameInput = () => this.page.getByPlaceholder('큐레이션명을 입력하세요'); - - /** 설명 입력 */ - readonly descriptionTextarea = () => - this.page.getByPlaceholder('큐레이션에 대한 설명을 입력하세요'); - - /** 노출 순서 입력 */ - readonly displayOrderInput = () => this.page.locator('input#displayOrder'); - - /** 활성화 상태 스위치 */ - readonly isActiveSwitch = () => this.page.locator('button#isActive'); - - /* Cards */ - - /** 기본 정보 카드 */ - readonly basicInfoCard = () => this.page.locator('text=기본 정보').locator('..'); - - /** 포함된 위스키 카드 */ - readonly whiskyCard = () => this.page.locator('text=포함된 위스키').locator('..'); - - /** 커버 이미지 카드 */ - readonly imageCard = () => this.page.locator('text=커버 이미지').locator('..'); - - /* Whisky Section */ - - /** 위스키 검색 입력 */ - readonly whiskySearchInput = () => this.page.getByPlaceholder('위스키 검색하여 추가...'); - - /** 위스키 목록 아이템 */ - readonly whiskyList = () => this.page.locator('ul.space-y-2 li'); - - /** 위스키 없음 상태 */ - readonly whiskyEmptyState = () => this.page.getByText('포함된 위스키가 없습니다.'); - - /** 특정 위스키의 제거 버튼 */ - readonly whiskyRemoveButton = (whiskyName: string) => - this.page.locator('li').filter({ hasText: whiskyName }).getByRole('button'); - - /* Delete Dialog */ - - /** 삭제 확인 다이얼로그 */ - readonly deleteDialog = () => this.page.getByRole('alertdialog'); - - /** 다이얼로그 내 삭제 확인 버튼 */ - readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' }); - - /* ============================================ - * Actions - * ============================================ */ - - /** - * 새 큐레이션 등록 페이지로 이동 - */ - async gotoNew() { - await this.page.goto('/curations/new'); - await this.waitForLoadingComplete(); - } - - /** - * 특정 큐레이션 상세 페이지로 이동 - */ - async gotoDetail(id: number) { - await this.page.goto(`/curations/${id}`); - await this.waitForLoadingComplete(); - } - - /** - * 로딩 완료 대기 - */ - async waitForLoadingComplete() { - await this.loadingState() - .waitFor({ state: 'hidden', timeout: 15000 }) - .catch(() => {}); - } - - /** - * 뒤로가기 - */ - async goBack() { - await this.backButton().click(); - } - - /** - * 저장/등록 버튼 클릭 - */ - async clickSave() { - await this.saveButton().click(); - } - - /** - * 삭제 버튼 클릭 - */ - async clickDelete() { - await this.deleteButton().click(); - } - - /** - * 삭제 확인 다이얼로그에서 삭제 클릭 - */ - async confirmDelete() { - await this.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); - await this.confirmDeleteButton().click(); - } - - /** - * 기본 정보 폼 입력 (필수/선택 필드) - */ - async fillBasicInfo(data: { name: string; description?: string; displayOrder?: number }) { - await this.nameInput().fill(data.name); - if (data.description) { - await this.descriptionTextarea().fill(data.description); - } - if (data.displayOrder !== undefined) { - await this.displayOrderInput().fill(String(data.displayOrder)); - } - } - - /** - * 큐레이션명만 수정 - */ - async updateName(name: string) { - await this.nameInput().fill(name); - } - - /** - * 활성화 상태 토글 - */ - async toggleActive() { - await this.isActiveSwitch().click(); - } - - /** 이미지 파일 입력 (hidden) */ - readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]'); - - /** 업로드된 이미지 미리보기 */ - readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]'); - - /** - * 테스트용 이미지 업로드 - */ - async uploadTestImage() { - const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png'); - await this.imageFileInput().setInputFiles(testImagePath); - // S3 업로드 완료 대기: 업로드된 이미지 미리보기가 표시될 때까지 대기 - await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 }); - } - - /** - * 커버 이미지가 없으면 테스트 이미지 업로드 - */ - async ensureCoverImage() { - const hasImage = await this.uploadedImage() - .isVisible() - .catch(() => false); - if (!hasImage) { - await this.uploadTestImage(); - } - } - - /** - * 위스키 검색 후 첫 번째 결과 선택 - * @returns 선택 성공 여부 - */ - async addFirstWhiskyBySearch(keyword: string): Promise { - await this.searchWhisky(keyword); - - const firstOption = this.whiskyDropdownList().locator('li button').first(); - const hasOptions = await firstOption.isVisible().catch(() => false); - if (!hasOptions) return false; - - await firstOption.click(); - return true; - } - - /** - * 위스키 검색 (키워드 입력 + debounce 300ms + API 응답 대기) - */ - async searchWhisky(keyword: string) { - await this.whiskySearchInput().fill(keyword); - // 컴포넌트 debounce (300ms) + API 응답 대기 - await this.page - .waitForResponse((resp) => resp.url().includes('/alcohols') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - // 드롭다운 렌더링 대기: 목록이 실제로 표시될 때까지 대기 - await this.whiskyDropdownList() - .waitFor({ state: 'visible', timeout: 5000 }) - .catch(() => {}); - } - - /** - * 위스키 검색 드롭다운 목록 (ul > li > button 구조) - */ - readonly whiskyDropdownList = () => this.page.getByTestId('whisky-search-dropdown'); - - /** - * 드롭다운에서 첫 번째 위스키 선택 - */ - async selectFirstWhiskyFromDropdown() { - const firstOption = this.whiskyDropdownList().locator('li button').first(); - await firstOption.click(); - } - - /** - * 드롭다운에서 특정 위스키 선택 (이름으로) - */ - async selectWhiskyFromDropdown(whiskyName: string) { - await this.whiskyDropdownList().locator('li button').filter({ hasText: whiskyName }).click(); - } - - /** - * 특정 위스키 제거 - */ - async removeWhisky(whiskyName: string) { - await this.whiskyRemoveButton(whiskyName).click(); - } - - /** - * 선택된 위스키 개수 확인 - */ - async getWhiskyCount() { - const isEmpty = await this.whiskyEmptyState() - .isVisible() - .catch(() => false); - if (isEmpty) return 0; - return await this.whiskyList().count(); - } -} diff --git a/e2e/pages/curation-list.page.ts b/e2e/pages/curation-list.page.ts deleted file mode 100644 index 1f7f091..0000000 --- a/e2e/pages/curation-list.page.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { type Page } from '@playwright/test'; -import { BasePage } from './base.page'; - -/** - * 큐레이션 목록 페이지 Page Object - * - * 실제 UI 구조: - * - 테이블 형태로 큐레이션 목록 표시 - * - 검색 + 상태 필터 (전체/활성/비활성) - * - 행 클릭 시 상세 페이지로 이동 - * - 인라인 Switch로 활성화 상태 토글 - * - 순서 변경 모드에서 드래그 앤 드롭으로 순서 변경 - */ -export class CurationListPage extends BasePage { - constructor(page: Page) { - super(page); - } - - /* ============================================ - * Locators - * ============================================ */ - - /** 페이지 제목 */ - readonly pageTitle = () => this.page.getByRole('heading', { name: '큐레이션 관리' }); - - /** 검색 입력 필드 */ - readonly searchInput = () => this.page.getByPlaceholder('큐레이션명으로 검색...'); - - /** 검색 버튼 */ - readonly searchButton = () => this.page.getByRole('button', { name: '검색' }); - - /** 큐레이션 등록 버튼 */ - readonly createButton = () => this.page.getByRole('button', { name: '큐레이션 등록' }); - - /** 상태 필터 드롭다운 트리거 */ - readonly statusFilterTrigger = () => - this.page.locator('button[role="combobox"]').filter({ hasText: /전체|활성|비활성/ }); - - /** 테이블 */ - readonly table = () => this.page.locator('table'); - - /** 테이블 행들 (헤더 제외) */ - readonly tableRows = () => this.page.locator('tbody tr'); - - /** 클릭 가능한 테이블 행 (cursor-pointer) */ - readonly clickableRows = () => this.page.locator('tbody tr.cursor-pointer'); - - /** 로딩 상태 */ - readonly loadingState = () => this.page.getByText('로딩 중...'); - - /** 검색 결과 없음 메시지 */ - readonly noResultMessage = () => this.page.getByText('검색 결과가 없습니다.'); - - /** 특정 큐레이션명을 포함한 행 */ - readonly rowWithName = (name: string) => this.page.locator('tbody tr').filter({ hasText: name }); - - /** - * 특정 행의 상태 토글 스위치 - * @param rowLocator - tableRows() 또는 rowWithName()으로 얻은 행 Locator - */ - readonly statusSwitch = (rowLocator: ReturnType) => - rowLocator.locator('button[role="switch"]'); - - /** 순서 변경 버튼 */ - readonly reorderButton = () => this.page.getByRole('button', { name: /순서 변경/ }); - - /** 순서 변경 모드 안내 배너 */ - readonly reorderModeBanner = () => this.page.getByText('순서 변경 모드'); - - /** 드래그 핸들 (순서 변경 모드에서만 표시) */ - readonly dragHandles = () => this.page.locator('tbody tr td.cursor-grab svg'); - - /** 특정 행의 순서 번호 (순서 변경 모드 시 첫 번째 컬럼) */ - readonly orderColumn = (rowLocator: ReturnType) => - rowLocator.locator('td').first(); - - /* ============================================ - * Actions - * ============================================ */ - - /** - * 페이지로 이동 - */ - async goto() { - await this.page.goto('/curations'); - await this.waitForLoadingComplete(); - } - - /** - * 로딩 완료 대기 - */ - async waitForLoadingComplete() { - await this.loadingState() - .waitFor({ state: 'hidden', timeout: 10000 }) - .catch(() => {}); - } - - /** - * 검색 수행 (Enter 키) - */ - async search(keyword: string) { - await this.searchInput().fill(keyword); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.searchInput().press('Enter'); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 검색 버튼 클릭으로 검색 수행 - */ - async searchWithButton(keyword: string) { - await this.searchInput().fill(keyword); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.searchButton().click(); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 상태 필터 선택 - * @param status - 전체|활성|비활성 - */ - async selectStatusFilter(status: '전체' | '활성' | '비활성') { - await this.statusFilterTrigger().click(); - // API 응답 대기 준비 - const responsePromise = this.page - .waitForResponse((resp) => resp.url().includes('/curations') && resp.status() === 200, { - timeout: 10000, - }) - .catch(() => {}); - await this.page.getByRole('option', { name: status, exact: true }).click(); - await responsePromise; - await this.waitForLoadingComplete(); - } - - /** - * 큐레이션 등록 버튼 클릭 - */ - async clickCreate() { - await this.createButton().click(); - } - - /** - * 첫 번째 행 클릭 - */ - async clickFirstRow() { - await this.clickableRows().first().click(); - } - - /** - * 특정 큐레이션명 행 클릭 - */ - async clickRowByName(name: string) { - await this.rowWithName(name).click(); - } - - /** - * 테이블 행 개수 반환 - */ - async getRowCount() { - const noResult = await this.noResultMessage() - .isVisible() - .catch(() => false); - if (noResult) return 0; - return await this.clickableRows().count(); - } - - /** - * 특정 행의 상태 토글 - */ - async toggleStatus(rowLocator: ReturnType) { - const switchBtn = this.statusSwitch(rowLocator); - await switchBtn.click(); - } - - /** - * 특정 큐레이션이 목록에 있는지 확인 - */ - async expectCurationExists(name: string) { - await this.rowWithName(name).waitFor({ state: 'visible' }); - } - - /** - * 특정 큐레이션이 목록에 없는지 확인 - */ - async expectCurationNotExists(name: string) { - await this.rowWithName(name).waitFor({ state: 'hidden' }); - } - - /** - * 순서 변경 모드 진입 - */ - async enterReorderMode() { - await this.reorderButton().click(); - await this.reorderModeBanner().waitFor({ state: 'visible', timeout: 5000 }); - await this.waitForLoadingComplete(); - await this.dragHandles() - .first() - .waitFor({ state: 'visible', timeout: 10000 }) - .catch(() => {}); - } - - /** - * 순서 변경 모드 종료 - */ - async exitReorderMode() { - await this.reorderButton().click(); - await this.reorderModeBanner().waitFor({ state: 'hidden', timeout: 5000 }); - } - - /** - * 순서 변경 모드 여부 확인 - */ - async isReorderMode() { - return await this.reorderModeBanner() - .isVisible() - .catch(() => false); - } - - /** - * 드래그 핸들 개수 확인 (순서 변경 모드에서만 표시) - */ - async getDragHandleCount() { - return await this.dragHandles().count(); - } -} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts index d513e7c..670aa45 100644 --- a/e2e/pages/index.ts +++ b/e2e/pages/index.ts @@ -6,7 +6,5 @@ export { LoginPage } from './login.page'; export { TastingTagListPage } from './tasting-tag-list.page'; export { WhiskyListPage } from './whisky-list.page'; export { WhiskyDetailPage } from './whisky-detail.page'; -export { CurationListPage } from './curation-list.page'; -export { CurationDetailPage } from './curation-detail.page'; export { BannerListPage } from './banner-list.page'; export { BannerDetailPage } from './banner-detail.page'; diff --git a/e2e/specs/curations.spec.ts b/e2e/specs/curations.spec.ts deleted file mode 100644 index 9d27f74..0000000 --- a/e2e/specs/curations.spec.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { CurationListPage } from '../pages/curation-list.page'; -import { CurationDetailPage } from '../pages/curation-detail.page'; - -/** - * 큐레이션 E2E 테스트 - * - * 실제 UI: - * - 테이블 형태로 큐레이션 목록 표시 - * - 검색 + 상태 필터 - * - 행 클릭 → 상세 페이지 - * - 인라인 Switch로 상태 토글 - */ - -test.describe('큐레이션 목록', () => { - let listPage: CurationListPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - }); - - test('목록 페이지에 접근할 수 있다', async ({ page }) => { - await listPage.goto(); - - await expect(page).toHaveURL(/.*curations/); - await expect(listPage.pageTitle()).toBeVisible(); - await expect(listPage.table()).toBeVisible(); - }); - - test('검색으로 큐레이션을 필터링할 수 있다', async () => { - await listPage.goto(); - - // 검색 수행 - await listPage.search('추천'); - - // 검색 결과가 있거나 "검색 결과가 없습니다" 메시지가 보여야 함 - const rowCount = await listPage.getRowCount(); - const hasNoResultMessage = await listPage - .noResultMessage() - .isVisible() - .catch(() => false); - - expect(rowCount > 0 || hasNoResultMessage).toBe(true); - }); - - test('상태로 필터링할 수 있다 (활성/비활성)', async ({ page }) => { - await listPage.goto(); - - // 활성 상태 필터 선택 - await listPage.selectStatusFilter('활성'); - - // URL에 isActive 파라미터가 추가되어야 함 - await expect(page).toHaveURL(/isActive=true/); - - // 비활성 상태 필터 선택 - await listPage.selectStatusFilter('비활성'); - await expect(page).toHaveURL(/isActive=false/); - - // 전체로 다시 선택 - await listPage.selectStatusFilter('전체'); - // 전체는 isActive 파라미터 없음 - await expect(page).not.toHaveURL(/isActive=/); - }); - - test('큐레이션을 클릭하면 상세 페이지로 이동한다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - - if (rowCount > 0) { - await listPage.clickFirstRow(); - await expect(page).toHaveURL(/.*curations\/\d+/); - } else { - test.skip(); - } - }); - - test('큐레이션 등록 버튼을 클릭하면 등록 페이지로 이동한다', async ({ page }) => { - await listPage.goto(); - - await listPage.clickCreate(); - - await expect(page).toHaveURL(/.*curations\/new/); - }); - - test('인라인 스위치로 상태를 토글할 수 있다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - // 첫 번째 행의 스위치 찾기 - const firstRow = listPage.clickableRows().first(); - const statusSwitch = listPage.statusSwitch(firstRow); - - // 현재 스위치 상태 확인 - const isCurrentlyChecked = (await statusSwitch.getAttribute('data-state')) === 'checked'; - const expectedNewState = isCurrentlyChecked ? 'unchecked' : 'checked'; - - // 스위치 클릭 - await statusSwitch.click(); - - // 스위치 상태가 변경될 때까지 대기 (polling) - await expect(statusSwitch).toHaveAttribute('data-state', expectedNewState, { timeout: 10000 }); - }); -}); - -test.describe('큐레이션 상세', () => { - let listPage: CurationListPage; - let detailPage: CurationDetailPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - detailPage = new CurationDetailPage(page); - }); - - test('상세 페이지에서 큐레이션 정보를 볼 수 있다', async () => { - // 목록에서 첫 번째 큐레이션으로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 상세 페이지 요소 확인 - await expect(detailPage.pageTitle()).toContainText('큐레이션 수정'); - await expect(detailPage.nameInput()).toBeVisible(); - await expect(detailPage.descriptionTextarea()).toBeVisible(); - await expect(detailPage.displayOrderInput()).toBeVisible(); - await expect(detailPage.isActiveSwitch()).toBeVisible(); - await expect(detailPage.saveButton()).toBeVisible(); - await expect(detailPage.deleteButton()).toBeVisible(); - }); - - test('뒤로가기 버튼을 클릭하면 목록으로 돌아간다', async ({ page }) => { - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 뒤로가기 버튼 클릭 - await detailPage.goBack(); - - // 목록 페이지로 돌아가야 함 - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/); - }); -}); - -test.describe('큐레이션 폼 리셋', () => { - test('상세 페이지에서 추가 페이지로 이동하면 폼이 초기화된다', async ({ page }) => { - const listPage = new CurationListPage(page); - const detailPage = new CurationDetailPage(page); - - // 1. 목록에서 큐레이션 선택 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 2. 폼 데이터 로딩 대기 (auto-retrying assertion) - await expect(detailPage.nameInput()).not.toHaveValue('', { timeout: 10000 }); - - // 3. 사이드바에서 "큐레이션 추가" 메뉴 클릭 - // 사이드바 메뉴 구조: 큐레이션 관리 > 큐레이션 추가 - await page.getByRole('button', { name: '큐레이션 관리' }).click(); - await page.getByRole('button', { name: '큐레이션 추가' }).click(); - - // 4. URL 확인 - await expect(page).toHaveURL(/.*curations\/new/); - await detailPage.waitForLoadingComplete(); - - // 5. 폼이 비어있어야 함 - await expect(detailPage.nameInput()).toHaveValue('', { timeout: 5000 }); - }); -}); - -test.describe('큐레이션 CRUD 플로우', () => { - const testCurationName = `테스트큐레이션_${Date.now()}`; - const updatedCurationName = `${testCurationName}_수정됨`; - - test('큐레이션 생성 → 수정 → 삭제 전체 플로우', async ({ page }) => { - const listPage = new CurationListPage(page); - const detailPage = new CurationDetailPage(page); - - // 1. 목록 페이지 이동 - await page.goto('/curations'); - await listPage.waitForLoadingComplete(); - - // 2. 큐레이션 등록 버튼 클릭 - await listPage.clickCreate(); - await expect(page).toHaveURL(/.*curations\/new/); - - // 3. 폼 입력 (필수 필드 모두 채우기) - await detailPage.fillBasicInfo({ - name: testCurationName, - description: 'E2E 테스트용 큐레이션입니다.', - displayOrder: 999, - }); - - // 3-1. 위스키 추가 (필수 - 최소 1개, skip 판단을 위해 이미지 업로드보다 먼저) - const added = await detailPage.addFirstWhiskyBySearch('글렌'); - if (!added) { - test.skip(); - return; - } - - // 3-2. 커버 이미지 업로드 (필수) - await detailPage.uploadTestImage(); - - // 4. 등록 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations') && resp.request().method() === 'POST', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 5. 저장 성공 확인 (생성 후에는 상세 페이지로 리다이렉트) - await expect(page).toHaveURL(/.*curations\/\d+/, { timeout: 10000 }); - await detailPage.waitForLoadingComplete(); - - // 6. 폼 수정 - await detailPage.updateName(updatedCurationName); - - // 7. 저장 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 8. 삭제 버튼 클릭 - await detailPage.clickDelete(); - - // 9. AlertDialog가 열릴 때까지 대기 - await detailPage.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); - - // 10. 삭제 확인 버튼 클릭 + API 응답 대기 - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'DELETE', - { timeout: 10000 } - ), - detailPage.confirmDelete(), - ]); - - // 11. 목록으로 리다이렉트 확인 - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/, { timeout: 10000 }); - }); -}); - -test.describe('큐레이션 순서 변경', () => { - let listPage: CurationListPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - }); - - test('순서 변경 버튼을 클릭하면 순서 변경 모드로 진입한다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 순서 변경 모드 안내 배너가 표시됨 - await expect(listPage.reorderModeBanner()).toBeVisible(); - - // 버튼 텍스트가 "순서 변경 완료"로 변경됨 - await expect(listPage.reorderButton()).toContainText('순서 변경 완료'); - - // 드래그 핸들이 표시됨 - const handleCount = await listPage.getDragHandleCount(); - expect(handleCount).toBeGreaterThan(0); - }); - - test('순서 변경 모드에서는 행 클릭으로 상세 페이지 이동이 안 된다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 첫 번째 행 클릭 - await listPage.tableRows().first().click(); - - // URL이 변경되지 않아야 함 (상세 페이지로 이동하지 않음) - await expect(page).toHaveURL(/.*\/curations(\?.*)?$/); - }); - - test('순서 변경 완료 버튼을 클릭하면 일반 모드로 돌아간다', async () => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount === 0) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - await expect(listPage.reorderModeBanner()).toBeVisible(); - - // 순서 변경 모드 종료 - await listPage.exitReorderMode(); - - // 안내 배너가 사라짐 - await expect(listPage.reorderModeBanner()).not.toBeVisible(); - - // 버튼 텍스트가 "순서 변경"으로 변경됨 - await expect(listPage.reorderButton()).toContainText('순서 변경'); - await expect(listPage.reorderButton()).not.toContainText('완료'); - }); - - test('순서 변경 모드에서 드래그 후 완료하면 bulk reorder API가 호출된다', async ({ page }) => { - await listPage.goto(); - - const rowCount = await listPage.getRowCount(); - if (rowCount < 2) { - test.skip(); - return; - } - - // 순서 변경 모드 진입 - await listPage.enterReorderMode(); - - // 첫 번째와 두 번째 행 가져오기 - const firstRow = listPage.tableRows().first(); - const secondRow = listPage.tableRows().nth(1); - - // 첫 번째 행을 두 번째 행 위치로 드래그 - await firstRow.dragTo(secondRow); - - // 완료 버튼을 눌러 저장하면 bulk reorder API가 호출됨 - const displayOrderPromise = page.waitForResponse( - (resp) => resp.url().includes('/bulk/reorder') && resp.request().method() === 'PATCH', - { timeout: 10000 } - ); - await listPage.reorderButton().click(); - - // API 호출 확인 - const response = await displayOrderPromise; - expect(response.ok()).toBe(true); - await expect(listPage.reorderModeBanner()).not.toBeVisible(); - }); -}); - -test.describe('큐레이션 위스키 관리', () => { - test.describe.configure({ mode: 'serial' }); - - let listPage: CurationListPage; - let detailPage: CurationDetailPage; - - test.beforeEach(async ({ page }) => { - listPage = new CurationListPage(page); - detailPage = new CurationDetailPage(page); - }); - - test('큐레이션에 위스키를 추가하고 저장할 수 있다', async ({ page }) => { - // 기존 큐레이션 상세로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 커버 이미지 없으면 업로드 (필수 필드) - await detailPage.ensureCoverImage(); - - // 현재 위스키 개수 확인 - const initialCount = await detailPage.getWhiskyCount(); - - // 위스키 검색 - await detailPage.searchWhisky('글렌'); - - // 드롭다운 아이템 대기 - const dropdownList = detailPage.whiskyDropdownList(); - const firstOption = dropdownList.locator('li button').first(); - - await expect(dropdownList).toBeVisible({ timeout: 10000 }); - const hasOptions = await firstOption.isVisible().catch(() => false); - - if (!hasOptions) { - test.skip(); - return; - } - - // 위스키 선택 (로컬 상태에 추가) - await firstOption.click(); - - // 위스키가 로컬 상태에 추가되었는지 확인 - const afterAddCount = await detailPage.getWhiskyCount(); - expect(afterAddCount).toBeGreaterThan(initialCount); - - // 저장 버튼 클릭 (PUT 요청 대기) - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 저장 성공 후 페이지 새로고침하여 서버 상태 확인 - await page.reload(); - await detailPage.waitForLoadingComplete(); - - // 서버에 저장된 위스키 개수 확인 - const savedCount = await detailPage.getWhiskyCount(); - expect(savedCount).toBeGreaterThan(initialCount); - }); - - test('큐레이션에서 위스키를 제거하고 저장할 수 있다', async ({ page }) => { - // 기존 큐레이션 상세로 이동 - await listPage.goto(); - const rowCount = await listPage.getRowCount(); - - if (rowCount === 0) { - test.skip(); - return; - } - - await listPage.clickFirstRow(); - await detailPage.waitForLoadingComplete(); - - // 커버 이미지 없으면 업로드 (필수 필드) - await detailPage.ensureCoverImage(); - - // 현재 위스키 개수 확인 - let currentCount = await detailPage.getWhiskyCount(); - - // 제거 후에도 최소 1개 위스키가 남도록 사전 데이터를 보강 - let shouldSavePreparedWhiskies = false; - while (currentCount < 2) { - const added = await detailPage.addFirstWhiskyBySearch('글렌'); - - if (!added) { - test.skip(); - return; - } - - const nextCount = await detailPage.getWhiskyCount(); - if (nextCount <= currentCount) { - test.skip(); - return; - } - - currentCount = nextCount; - shouldSavePreparedWhiskies = true; - } - - if (shouldSavePreparedWhiskies) { - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - await page.reload(); - await detailPage.waitForLoadingComplete(); - currentCount = await detailPage.getWhiskyCount(); - } - - // 첫 번째 위스키의 제거 버튼 클릭 - const firstWhiskyItem = detailPage.whiskyList().first(); - const removeButton = firstWhiskyItem.getByRole('button'); - await removeButton.click(); - - // 로컬 상태에서 제거되었는지 확인 - const afterRemoveCount = await detailPage.getWhiskyCount(); - expect(afterRemoveCount).toBeLessThan(currentCount); - - // 저장 버튼 클릭 (PUT 요청 대기) - await Promise.all([ - page.waitForResponse( - (resp) => resp.url().includes('/curations/') && resp.request().method() === 'PUT', - { timeout: 10000 } - ), - detailPage.clickSave(), - ]); - - // 저장 성공 후 페이지 새로고침하여 서버 상태 확인 - await page.reload(); - await detailPage.waitForLoadingComplete(); - - // 서버에 저장된 위스키 개수 확인 - const savedCount = await detailPage.getWhiskyCount(); - expect(savedCount).toBeLessThan(currentCount); - }); -}); diff --git a/scripts/migrate-curation-banner-links.mjs b/scripts/migrate-curation-banner-links.mjs new file mode 100644 index 0000000..5150158 --- /dev/null +++ b/scripts/migrate-curation-banner-links.mjs @@ -0,0 +1,211 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const APPLY_FLAG = '--apply'; +const PAGE_SIZE = 100; +const DEVELOPMENT_API_HOST = 'admin-api.development.bottle-note.com'; +const LEGACY_CURATION_PATHS = new Set(['/search', '/alcohols/search']); + +const isApplyMode = process.argv.includes(APPLY_FLAG); + +function parseEnvFile(content) { + return Object.fromEntries( + content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const separatorIndex = line.indexOf('='); + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + const value = rawValue.replace(/^(['"])(.*)\1$/, '$2'); + return [key, value]; + }) + .filter(([key]) => key) + ); +} + +async function loadMigrationEnvironment() { + const envFile = await readFile(resolve('.env.dev'), 'utf8'); + const fileEnv = parseEnvFile(envFile); + const env = { + ...fileEnv, + ...process.env, + }; + const requiredKeys = ['VITE_API_BASE_URL', 'VITE_E2E_TEST_ID', 'VITE_E2E_TEST_PW']; + const missingKeys = requiredKeys.filter((key) => !env[key]); + + if (missingKeys.length > 0) { + throw new Error(`Missing required environment variables: ${missingKeys.join(', ')}`); + } + + return { + apiBaseUrl: env.VITE_API_BASE_URL.replace(/\/$/, ''), + email: env.VITE_E2E_TEST_ID, + password: env.VITE_E2E_TEST_PW, + }; +} + +function getCurationIdFromLegacyTargetUrl(targetUrl) { + if (typeof targetUrl !== 'string' || !targetUrl.startsWith('/')) return null; + + const url = new URL(targetUrl, 'https://bottlenote.local'); + if (!LEGACY_CURATION_PATHS.has(url.pathname)) return null; + + const curationIds = url.searchParams.getAll('curationId'); + if (curationIds.length !== 1 || !/^\d+$/.test(curationIds[0])) return null; + + const curationId = Number(curationIds[0]); + return Number.isSafeInteger(curationId) && curationId > 0 ? curationId : null; +} + +function toV2TargetUrl(curationId) { + return `/curation/${curationId}`; +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + const body = await response.json().catch(() => null); + + if (!response.ok || !body?.success) { + const message = body?.errors?.[0]?.message ?? body?.message ?? `HTTP ${response.status}`; + throw new Error(message); + } + + return body; +} + +async function login(apiBaseUrl, email, password) { + const response = await requestJson(`${apiBaseUrl}/admin/api/v1/auth/login`, { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + + if (!response.data?.accessToken) { + throw new Error('Login response did not include an access token'); + } + + return response.data.accessToken; +} + +async function fetchAllCurationBannerIds(apiBaseUrl, accessToken) { + const bannerIds = []; + let page = 0; + + while (true) { + const url = new URL(`${apiBaseUrl}/admin/api/v1/banners`); + url.searchParams.set('bannerType', 'CURATION'); + url.searchParams.set('page', String(page)); + url.searchParams.set('size', String(PAGE_SIZE)); + + const response = await requestJson(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const items = Array.isArray(response.data) ? response.data : []; + bannerIds.push(...items.map((banner) => banner.id).filter(Number.isSafeInteger)); + + const totalPages = Number(response.meta?.totalPages ?? 0); + if (totalPages === 0 || page + 1 >= totalPages) break; + page += 1; + } + + return [...new Set(bannerIds)]; +} + +function buildUpdatePayload(banner, targetUrl) { + return { + name: banner.name, + nameFontColor: banner.nameFontColor, + descriptionA: banner.descriptionA, + descriptionB: banner.descriptionB, + descriptionFontColor: banner.descriptionFontColor, + imageUrl: banner.imageUrl, + textPosition: banner.textPosition, + targetUrl, + isExternalUrl: false, + bannerType: banner.bannerType, + sortOrder: banner.sortOrder, + startDate: banner.startDate, + endDate: banner.endDate, + isActive: banner.isActive, + mediaType: banner.mediaType, + }; +} + +async function migrate() { + const { apiBaseUrl, email, password } = await loadMigrationEnvironment(); + const apiUrl = new URL(apiBaseUrl); + + if (isApplyMode && apiUrl.hostname !== DEVELOPMENT_API_HOST) { + throw new Error('Refusing to apply migration outside the development admin API host'); + } + + const accessToken = await login(apiBaseUrl, email, password); + const bannerIds = await fetchAllCurationBannerIds(apiBaseUrl, accessToken); + const results = { + mode: isApplyMode ? 'apply' : 'dry-run', + apiBaseUrl, + scannedBannerIds: bannerIds, + updated: [], + skipped: [], + failed: [], + }; + + for (const bannerId of bannerIds) { + try { + const detailResponse = await requestJson(`${apiBaseUrl}/admin/api/v1/banners/${bannerId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const banner = detailResponse.data; + + if (banner.bannerType !== 'CURATION' || banner.isExternalUrl) { + results.skipped.push({ bannerId, reason: 'not-an-internal-curation-banner' }); + continue; + } + + const curationId = getCurationIdFromLegacyTargetUrl(banner.targetUrl); + if (!curationId) { + const reason = /^\/curation\/\d+$/.test(banner.targetUrl ?? '') + ? 'already-v2-link' + : 'unsupported-or-malformed-link'; + results.skipped.push({ bannerId, reason, targetUrl: banner.targetUrl ?? null }); + continue; + } + + const targetUrl = toV2TargetUrl(curationId); + if (isApplyMode) { + await requestJson(`${apiBaseUrl}/admin/api/v1/banners/${bannerId}`, { + method: 'PUT', + headers: { Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify(buildUpdatePayload(banner, targetUrl)), + }); + } + + results.updated.push({ + bannerId, + from: banner.targetUrl, + to: targetUrl, + applied: isApplyMode, + }); + } catch (error) { + results.failed.push({ + bannerId, + reason: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + console.log(JSON.stringify(results, null, 2)); + if (results.failed.length > 0) process.exitCode = 1; +} + +migrate().catch((error) => { + console.error(error instanceof Error ? error.message : 'Migration failed'); + process.exitCode = 1; +}); diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index e350bba..7fd4d4d 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -151,32 +151,6 @@ export const menuConfig: MenuGroup[] = [ }, ], }, - // Deprecated: 예전 큐레이션 관리 라우트(/curations)는 더 이상 사용하지 않아 nav에서 제외합니다. - // { - // id: 'curation', - // items: [ - // { - // id: 'curation-management', - // label: '큐레이션 관리', - // icon: Layers, - // roles: ['ROOT_ADMIN'], - // children: [ - // { - // id: 'curation-list', - // label: '큐레이션 목록', - // icon: List, - // path: '/curations', - // }, - // { - // id: 'curation-create', - // label: '큐레이션 추가', - // icon: Plus, - // path: '/curations/new', - // }, - // ], - // }, - // ], - // }, { id: 'inquiry', items: [ diff --git a/src/data/mock/curations.mock.ts b/src/data/mock/curations.mock.ts deleted file mode 100644 index 88ab4fa..0000000 --- a/src/data/mock/curations.mock.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 큐레이션 Mock 데이터 - * TODO: 실제 API 연동 시 제거 - */ - -export interface CurationListItem { - id: number; - name: string; - description?: string; -} - -/** - * 큐레이션 목록 Mock 데이터 - */ -export const MOCK_CURATIONS: CurationListItem[] = [ - { id: 1, name: '봄 추천 위스키', description: '봄에 어울리는 상쾌한 위스키' }, - { id: 2, name: '입문자 추천', description: '위스키 입문자를 위한 선택' }, - { id: 3, name: '선물용 추천', description: '선물하기 좋은 위스키' }, - { id: 4, name: '가성비 위스키', description: '가격 대비 품질 좋은 위스키' }, - { id: 5, name: '프리미엄 컬렉션', description: '특별한 날을 위한 프리미엄' }, - { id: 6, name: '하이볼 추천', description: '하이볼에 어울리는 위스키' }, - { id: 7, name: '피트 위스키', description: '스모키한 피트향 위스키' }, - { id: 8, name: '셰리캐스크 위스키', description: '셰리캐스크 숙성 위스키' }, - { id: 9, name: '여름 추천', description: '시원하게 즐기는 여름 위스키' }, - { id: 10, name: '겨울 추천', description: '따뜻하게 즐기는 겨울 위스키' }, -]; diff --git a/src/hooks/__tests__/useCurationOld.test.ts b/src/hooks/__tests__/useCurationOld.test.ts deleted file mode 100644 index 06e6639..0000000 --- a/src/hooks/__tests__/useCurationOld.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { server } from '@/test/mocks/server'; -import { renderHook } from '@/test/test-utils'; -import { wrapApiError } from '@/test/mocks/data'; -import { useCurationList, useCurationBulkReorder } from '../useCurationOld'; - -const BASE = '/admin/api/v1/curations'; - -describe('useCurationOld hooks', () => { - describe('useCurationList', () => { - it('목록 데이터를 반환한다', async () => { - const { result } = renderHook(() => useCurationList()); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data!.items.length).toBeGreaterThan(0); - expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); - }); - }); - - describe('useCurationBulkReorder', () => { - it('일괄 노출순서 변경 mutation이 성공한다', async () => { - const onSuccess = vi.fn(); - const { result } = renderHook(() => useCurationBulkReorder({ onSuccess })); - - result.current.mutate({ ids: [2, 1] }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(onSuccess).toHaveBeenCalled(); - }); - - it('에러 시 에러 상태가 된다', async () => { - server.use( - http.patch(`${BASE}/bulk/reorder`, () => { - return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { - status: 500, - }); - }) - ); - - const { result } = renderHook(() => useCurationBulkReorder()); - - result.current.mutate({ ids: [2, 1] }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - }); - }); -}); diff --git a/src/hooks/__tests__/useCurations.test.ts b/src/hooks/__tests__/useCurations.test.ts index 54b146f..b3c4be1 100644 --- a/src/hooks/__tests__/useCurations.test.ts +++ b/src/hooks/__tests__/useCurations.test.ts @@ -117,6 +117,7 @@ describe('useCurations hooks', () => { const url = new URL(request.url); expect(url.searchParams.get('code')).toBe('RECOMMENDED_WHISKY'); + expect(url.searchParams.get('isActive')).toBe('true'); return HttpResponse.json( wrapApiResponse( @@ -144,7 +145,7 @@ describe('useCurations hooks', () => { ); const { result } = renderHook(() => - useCurationList({ code: 'RECOMMENDED_WHISKY', page: 0, size: 20 }) + useCurationList({ code: 'RECOMMENDED_WHISKY', isActive: true, page: 0, size: 20 }) ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/src/hooks/useCurationOld.ts b/src/hooks/useCurationOld.ts deleted file mode 100644 index 8a66329..0000000 --- a/src/hooks/useCurationOld.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * 큐레이션 API 커스텀 훅 - */ - -import { useQueryClient } from '@tanstack/react-query'; -import { useApiQuery } from './useApiQuery'; -import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; -import { - curationService, - curationKeys, - type CurationListResponse, -} from '@/services/curation-old.service'; -import type { - CurationSearchParams, - CurationDetail, - CurationCreateRequest, - CurationCreateResponse, - CurationUpdateRequest, - CurationUpdateResponse, - CurationDeleteResponse, - CurationToggleStatusRequest, - CurationToggleStatusResponse, - CurationBulkReorderResponse, - CurationAddAlcoholsRequest, - CurationAddAlcoholsResponse, - CurationRemoveAlcoholResponse, -} from '@/types/api'; - -/** - * 큐레이션 목록 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useCurationList({ keyword: '신년' }); - * - * if (data) { - * console.log(data.items); // 큐레이션 목록 - * console.log(data.meta); // 페이지네이션 정보 - * } - * ``` - */ -export function useCurationList(params?: CurationSearchParams) { - return useApiQuery( - curationKeys.list(params), - () => curationService.search(params), - { - staleTime: 1000 * 60 * 5, // 5분 - } - ); -} - -/** - * 큐레이션 상세 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useCurationDetail(1); - * - * if (data) { - * console.log(data.name); // 큐레이션명 - * console.log(data.alcohols); // 포함된 위스키 목록 - * } - * ``` - */ -export function useCurationDetail(curationId: number | undefined) { - return useApiQuery( - curationKeys.detail(curationId ?? 0), - () => curationService.getDetail(curationId!), - { - enabled: !!curationId && curationId > 0, - staleTime: 1000 * 60 * 5, // 5분 - } - ); -} - -/** - * 큐레이션 생성 훅 - * - * @example - * ```tsx - * const createMutation = useCurationCreate({ - * onSuccess: (data) => { - * console.log('생성된 ID:', data.targetId); - * navigate('/curations'); - * }, - * }); - * - * createMutation.mutate({ - * name: '신년 특집', - * description: '새해를 맞이하는 특별한 위스키', - * // ... - * }); - * ``` - */ -export function useCurationCreate( - options?: Omit< - UseApiMutationOptions, - 'successMessage' - > -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - curationService.create, - { - successMessage: '큐레이션이 등록되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationCreateResponse, variables: CurationCreateRequest, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 수정 mutation 변수 타입 - */ -export interface CurationUpdateVariables { - curationId: number; - data: CurationUpdateRequest; -} - -/** - * 큐레이션 수정 훅 - * - * @example - * ```tsx - * const updateMutation = useCurationUpdate({ - * onSuccess: () => { - * console.log('수정 완료'); - * }, - * }); - * - * updateMutation.mutate({ - * curationId: 1, - * data: { - * name: '수정된 큐레이션', - * description: '새로운 설명', - * // ... - * }, - * }); - * ``` - */ -export function useCurationUpdate( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.update(curationId, data), - { - successMessage: '큐레이션이 수정되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationUpdateResponse, variables: CurationUpdateVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 삭제 훅 - * - * @example - * ```tsx - * const deleteMutation = useCurationDelete({ - * onSuccess: () => { - * navigate('/curations'); - * }, - * }); - * - * deleteMutation.mutate(curationId); - * ``` - */ -export function useCurationDelete( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - curationService.delete, - { - successMessage: '큐레이션이 삭제되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationDeleteResponse, variables: number, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 활성화 상태 토글 mutation 변수 타입 - */ -export interface CurationToggleStatusVariables { - curationId: number; - data: CurationToggleStatusRequest; -} - -/** - * 큐레이션 활성화 상태 토글 훅 - * - * @example - * ```tsx - * const toggleMutation = useCurationToggleStatus({ - * onSuccess: () => { - * console.log('상태 변경 완료'); - * }, - * }); - * - * toggleMutation.mutate({ - * curationId: 1, - * data: { isActive: false }, - * }); - * ``` - */ -export function useCurationToggleStatus( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.toggleStatus(curationId, data), - { - successMessage: '큐레이션 상태가 변경되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationToggleStatusResponse, variables: CurationToggleStatusVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** 큐레이션 노출순서 일괄 변경 훅 */ -export function useCurationBulkReorder( - options?: Omit< - UseApiMutationOptions, - 'successMessage' - > -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ ids }) => curationService.bulkReorder(ids), - { - successMessage: '큐레이션 순서가 변경되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ - queryKey: curationKeys.lists(), - refetchType: 'none', - }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - ( - onSuccess as ( - data: CurationBulkReorderResponse, - variables: { ids: number[] }, - context: unknown - ) => void - )(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 추가 mutation 변수 타입 - */ -export interface CurationAddAlcoholsVariables { - curationId: number; - data: CurationAddAlcoholsRequest; -} - -/** - * 큐레이션 위스키 추가 훅 - * - * @example - * ```tsx - * const addMutation = useCurationAddAlcohols({ - * onSuccess: () => { - * console.log('위스키 추가 완료'); - * }, - * }); - * - * addMutation.mutate({ - * curationId: 1, - * data: { alcoholIds: [10, 20, 30] }, - * }); - * ``` - */ -export function useCurationAddAlcohols( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, data }) => curationService.addAlcohols(curationId, data), - { - successMessage: '위스키가 추가되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationAddAlcoholsResponse, variables: CurationAddAlcoholsVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 제거 mutation 변수 타입 - */ -export interface CurationRemoveAlcoholVariables { - curationId: number; - alcoholId: number; -} - -/** - * 큐레이션 위스키 제거 훅 - * - * @example - * ```tsx - * const removeMutation = useCurationRemoveAlcohol({ - * onSuccess: () => { - * console.log('위스키 제거 완료'); - * }, - * }); - * - * removeMutation.mutate({ - * curationId: 1, - * alcoholId: 10, - * }); - * ``` - */ -export function useCurationRemoveAlcohol( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, alcoholId }) => curationService.removeAlcohol(curationId, alcoholId), - { - successMessage: '위스키가 제거되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: CurationRemoveAlcoholResponse, variables: CurationRemoveAlcoholVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -/** - * 큐레이션 위스키 벌크 제거 mutation 변수 타입 - */ -export interface CurationRemoveAlcoholsVariables { - curationId: number; - alcoholIds: number[]; -} - -/** - * 큐레이션 위스키 벌크 제거 훅 - * API가 단건만 지원하므로 내부적으로 병렬 호출 - * - * @example - * ```tsx - * const removeMutation = useCurationRemoveAlcohols({ - * onSuccess: () => { - * console.log('위스키 벌크 제거 완료'); - * }, - * }); - * - * removeMutation.mutate({ - * curationId: 1, - * alcoholIds: [10, 20, 30], - * }); - * ``` - */ -export function useCurationRemoveAlcohols( - options?: Omit, 'successMessage'> -) { - const queryClient = useQueryClient(); - const { onSuccess, ...restOptions } = options ?? {}; - - return useApiMutation( - ({ curationId, alcoholIds }) => curationService.removeAlcohols(curationId, alcoholIds), - { - successMessage: '위스키가 제거되었습니다.', - ...restOptions, - onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 - queryClient.invalidateQueries({ queryKey: curationKeys.lists() }); - // 상세 캐시 무효화 (alcohols 배열 변경) - queryClient.invalidateQueries({ queryKey: curationKeys.detail(variables.curationId) }); - // 원래 onSuccess 콜백 호출 - if (onSuccess) { - (onSuccess as (data: void, variables: CurationRemoveAlcoholsVariables, context: unknown) => void)(data, variables, context); - } - }, - } - ); -} - -// Re-export curationService for convenience -export { curationService }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 996515e..b61b778 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -8,7 +8,7 @@ import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; import { useHelpList } from '@/hooks/useHelps'; import { useTastingTagList } from '@/hooks/useTastingTags'; import { useBannerList } from '@/hooks/useBanners'; -import { useCurationList } from '@/hooks/useCurationOld'; +import { useCurationList } from '@/hooks/useCurations'; interface StatCardProps { title: string; @@ -93,7 +93,7 @@ export function DashboardPage() { value={curationData?.meta.totalElements ?? 0} icon={} isLoading={isCurationLoading} - href="/curations" + href="/dashboard/curations" /> (null); diff --git a/src/pages/banners/__tests__/banner-curation-link.test.ts b/src/pages/banners/__tests__/banner-curation-link.test.ts new file mode 100644 index 0000000..f19b4ef --- /dev/null +++ b/src/pages/banners/__tests__/banner-curation-link.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + createCurationTargetUrl, + extractCurationIdFromTargetUrl, +} from '../banner-curation-link'; + +describe('banner curation links', () => { + it('V2 큐레이션 상세 링크를 생성한다', () => { + expect(createCurationTargetUrl(42)).toBe('/curation/42'); + }); + + it.each([ + ['/curation/42', 42], + ['/search?curationId=42', 42], + ['/alcohols/search?curationId=42', 42], + ])('V2 및 레거시 링크에서 큐레이션 ID를 추출한다', (targetUrl, expectedId) => { + expect(extractCurationIdFromTargetUrl(targetUrl)).toBe(expectedId); + }); + + it.each([ + undefined, + '', + '/curation/0', + '/curation/-1', + '/curation/abc', + '/search', + '/search?curationId=1&curationId=2', + '/search?curationId=1.5', + '/other?curationId=42', + 'https://example.com/search?curationId=42', + ])('안전하지 않거나 관련 없는 링크는 추출하지 않는다: %s', (targetUrl) => { + expect(extractCurationIdFromTargetUrl(targetUrl)).toBeNull(); + }); +}); diff --git a/src/pages/banners/banner-curation-link.ts b/src/pages/banners/banner-curation-link.ts new file mode 100644 index 0000000..e2dfd0a --- /dev/null +++ b/src/pages/banners/banner-curation-link.ts @@ -0,0 +1,30 @@ +const LEGACY_CURATION_PATHS = new Set(['/search', '/alcohols/search']); + +export function createCurationTargetUrl(curationId: number): string { + return `/curation/${curationId}`; +} + +export function extractCurationIdFromTargetUrl( + targetUrl: string | null | undefined +): number | null { + if (!targetUrl?.startsWith('/')) return null; + + const url = new URL(targetUrl, 'https://bottlenote.local'); + const v2Match = url.pathname.match(/^\/curation\/(\d+)$/); + + if (v2Match?.[1]) { + return toPositiveInteger(v2Match[1]); + } + + if (!LEGACY_CURATION_PATHS.has(url.pathname)) return null; + + const curationIds = url.searchParams.getAll('curationId'); + return curationIds.length === 1 ? toPositiveInteger(curationIds[0] ?? null) : null; +} + +function toPositiveInteger(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) return null; + + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} diff --git a/src/pages/banners/components/BannerLinkSettingsCard.tsx b/src/pages/banners/components/BannerLinkSettingsCard.tsx index c40e837..b363874 100644 --- a/src/pages/banners/components/BannerLinkSettingsCard.tsx +++ b/src/pages/banners/components/BannerLinkSettingsCard.tsx @@ -15,12 +15,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { FormField } from '@/components/common/FormField'; import type { BannerFormValues } from '../banner.schema'; -import { curationService } from '@/hooks/useCurationOld'; -import type { CurationListItem } from '@/types/api'; +import { createCurationTargetUrl } from '../banner-curation-link'; +import type { CurationV2ListItem } from '@/types/api'; interface BannerLinkSettingsCardProps { form: UseFormReturn; - curations: CurationListItem[]; + curations: CurationV2ListItem[]; } export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCardProps) { @@ -32,7 +32,7 @@ export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCa const handleCurationChange = (value: string) => { const id = parseInt(value, 10); form.setValue('curationId', id); - form.setValue('targetUrl', curationService.generateCurationUrl(id)); + form.setValue('targetUrl', createCurationTargetUrl(id)); form.setValue('isExternalUrl', false); }; diff --git a/src/pages/banners/useBannerDetailForm.ts b/src/pages/banners/useBannerDetailForm.ts index 859a932..4810b6e 100644 --- a/src/pages/banners/useBannerDetailForm.ts +++ b/src/pages/banners/useBannerDetailForm.ts @@ -23,17 +23,7 @@ import { } from './banner.schema'; import type { BannerFormValues } from './banner.schema'; import type { BannerDetail } from '@/types/api'; - -/** - * 큐레이션 URL에서 curationId 추출 - * @param url - /alcohols/search?curationId=123 형태의 URL - * @returns curationId 또는 null - */ -function parseCurationIdFromUrl(url: string | null | undefined): number | null { - if (!url) return null; - const match = url.match(/curationId=(\d+)/); - return match && match[1] ? parseInt(match[1], 10) : null; -} +import { extractCurationIdFromTargetUrl } from './banner-curation-link'; /** * useBannerDetailForm 훅의 반환 타입 @@ -96,7 +86,7 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm // CURATION 타입인 경우 URL에서 curationId 추출 const curationId = bannerData.bannerType === 'CURATION' - ? parseCurationIdFromUrl(bannerData.targetUrl) + ? extractCurationIdFromTargetUrl(bannerData.targetUrl) : null; form.reset({ diff --git a/src/pages/curation-old/CurationDetail.tsx b/src/pages/curation-old/CurationDetail.tsx deleted file mode 100644 index 87e46d8..0000000 --- a/src/pages/curation-old/CurationDetail.tsx +++ /dev/null @@ -1,323 +0,0 @@ -/** - * 큐레이션 상세/등록 페이지 - * - 신규 등록 (id가 'new'인 경우) - * - 상세 조회 및 수정 (id가 숫자인 경우) - * - 위스키 연결은 로컬 상태로 관리하다가 저장 시 PUT API로 일괄 처리 - */ - -import { useState, useEffect } from 'react'; -import { useParams } from 'react-router'; -import { Save, Trash2, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Switch } from '@/components/ui/switch'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; -import { ImageUpload } from '@/components/common/ImageUpload'; -import { FormField } from '@/components/common/FormField'; -import { WhiskySearchSelect, type SelectedWhisky } from '@/components/common/WhiskySearchSelect'; - -import { useCurationDetailForm } from './useCurationDetailForm'; -import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; - -export function CurationDetailPage() { - const { id } = useParams<{ id: string }>(); - - // 폼 관련 로직을 커스텀 훅으로 분리 - const { - form, - isLoading, - isNewMode, - isPending, - curationData, - onSubmit, - handleBack, - handleDelete, - } = useCurationDetailForm(id); - - // 이미지 업로드 훅 (큐레이션 전용 S3 경로 사용) - const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({ - rootPath: S3UploadPath.CURATION, - }); - - // 로컬 상태 - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedWhiskies, setSelectedWhiskies] = useState([]); - - // curationData 변경 시 로컬 상태 동기화 - useEffect(() => { - if (curationData) { - setImagePreviewUrl(curationData.coverImageUrl); - setSelectedWhiskies( - curationData.alcohols.map((a) => ({ - alcoholId: a.alcoholId, - korName: a.korName, - engName: a.engName, - imageUrl: a.imageUrl, - })) - ); - } - }, [curationData]); - - const handleImageChange = async (file: File | null, previewUrl: string | null) => { - // 즉시 프리뷰 표시 - setImagePreviewUrl(previewUrl); - - if (file) { - // S3에 업로드하고 CDN URL 획득 - const viewUrl = await uploadImage(file); - if (viewUrl) { - // 업로드 성공 시 CDN URL로 업데이트 - form.setValue('coverImageUrl', viewUrl); - } else { - // 업로드 실패 시 프리뷰 URL 유지 (에러는 훅에서 처리) - form.setValue('coverImageUrl', previewUrl ?? ''); - } - } else { - // 이미지 삭제 시 - form.setValue('coverImageUrl', previewUrl ?? ''); - } - }; - - // 위스키 추가 (로컬 상태만 - 저장 시 API 호출) - const handleAddWhisky = (whisky: SelectedWhisky) => { - // 폼 상태에 추가 (유효성 검사용) - const currentIds = form.getValues('alcoholIds'); - form.setValue('alcoholIds', [...currentIds, whisky.alcoholId], { shouldValidate: true }); - // 로컬 상태에 추가 - setSelectedWhiskies((prev) => [...prev, whisky]); - }; - - // 위스키 제거 (로컬 상태만 - 저장 시 API 호출) - const handleRemoveWhisky = (alcoholId: number) => { - // 폼 상태에서 제거 (유효성 검사용) - const currentIds = form.getValues('alcoholIds'); - form.setValue( - 'alcoholIds', - currentIds.filter((id) => id !== alcoholId), - { shouldValidate: true } - ); - // 로컬 상태에서 제거 - setSelectedWhiskies((prev) => prev.filter((w) => w.alcoholId !== alcoholId)); - }; - - const handleSubmit = form.handleSubmit( - (data) => { - // PUT API가 alcoholIds를 포함하므로 단일 호출로 처리 - onSubmit(data); - }, - (errors) => { - console.log('[DEBUG] Form validation errors:', errors); - } - ); - - const handleDeleteConfirm = () => { - handleDelete(); - setIsDeleteDialogOpen(false); - }; - - // 폼 값 watch - const isActive = form.watch('isActive'); - - // 현재 선택된 위스키 ID 목록 (excludeIds용) - const alcoholIds = form.watch('alcoholIds'); - - return ( -
- {/* 헤더 */} - - {curationData && ( - - )} - - - } - /> - - {isLoading ? ( -
로딩 중...
- ) : ( -
- {/* 왼쪽 컬럼 */} -
- {/* 기본 정보 */} - - - 기본 정보 - - - - - - - -