diff --git a/VERSION b/VERSION index 8fc77d0..f8f3c08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.17 +1.0.18 diff --git a/e2e/fixtures/test-image.png b/e2e/fixtures/test-image.png new file mode 100644 index 0000000..0f2de37 Binary files /dev/null and b/e2e/fixtures/test-image.png differ diff --git a/e2e/pages/curation-detail.page.ts b/e2e/pages/curation-detail.page.ts index bf8838c..a3e12c3 100644 --- a/e2e/pages/curation-detail.page.ts +++ b/e2e/pages/curation-detail.page.ts @@ -1,6 +1,10 @@ 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 * @@ -170,6 +174,47 @@ export class CurationDetailPage extends BasePage { 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 응답 대기) */ diff --git a/e2e/specs/curations.spec.ts b/e2e/specs/curations.spec.ts index 40336e8..081825a 100644 --- a/e2e/specs/curations.spec.ts +++ b/e2e/specs/curations.spec.ts @@ -208,13 +208,23 @@ test.describe('큐레이션 CRUD 플로우', () => { await listPage.clickCreate(); await expect(page).toHaveURL(/.*curations\/new/); - // 3. 폼 입력 + // 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( @@ -386,6 +396,9 @@ test.describe('큐레이션 위스키 관리', () => { await listPage.clickFirstRow(); await detailPage.waitForLoadingComplete(); + // 커버 이미지 없으면 업로드 (필수 필드) + await detailPage.ensureCoverImage(); + // 현재 위스키 개수 확인 const initialCount = await detailPage.getWhiskyCount(); @@ -442,6 +455,9 @@ test.describe('큐레이션 위스키 관리', () => { await listPage.clickFirstRow(); await detailPage.waitForLoadingComplete(); + // 커버 이미지 없으면 업로드 (필수 필드) + await detailPage.ensureCoverImage(); + // 현재 위스키 개수 확인 let currentCount = await detailPage.getWhiskyCount(); diff --git a/e2e/specs/tasting-tags.spec.ts b/e2e/specs/tasting-tags.spec.ts index 94e32e6..af1d405 100644 --- a/e2e/specs/tasting-tags.spec.ts +++ b/e2e/specs/tasting-tags.spec.ts @@ -94,9 +94,8 @@ test.describe('테이스팅 태그 폼 리셋', () => { await expect(page).toHaveURL(/.*tasting-tags\/new/); await page.getByText('로딩 중...').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); - // 5. 폼이 비어있어야 함 - const newKorName = await page.getByPlaceholder('예: 바닐라').inputValue(); - expect(newKorName).toBe(''); + // 5. 폼이 비어있어야 함 (auto-retry: React useEffect 실행 대기) + await expect(page.getByPlaceholder('예: 바닐라')).toHaveValue(''); }); }); diff --git a/package.json b/package.json index 345a640..323d16d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bottlenote-admin", "private": true, - "version": "1.0.17", + "version": "1.0.18", "type": "module", "scripts": { "dev:local": "./scripts/ensure-env.sh local && vite", diff --git a/src/pages/curations/CurationList.tsx b/src/pages/curations/CurationList.tsx index e0ad091..7b74e57 100644 --- a/src/pages/curations/CurationList.tsx +++ b/src/pages/curations/CurationList.tsx @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; -import { Search, ImageOff, Plus, Check, X, GripVertical, ArrowUpDown } from 'lucide-react'; +import { Search, Plus, Check, X, GripVertical, ArrowUpDown } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Select, @@ -244,13 +244,6 @@ export function CurationListPage() { setDragOverId(null); }; - // 설명 텍스트 truncate - const truncateDescription = (description: string | null, maxLength: number = 50) => { - if (!description) return '-'; - if (description.length <= maxLength) return description; - return `${description.slice(0, maxLength)}...`; - }; - return (
{/* 헤더 */} @@ -316,29 +309,27 @@ export function CurationListPage() { {/* 테이블 */}
- +
{isReorderMode && 순서} - 이미지 큐레이션명 - 설명 - 위스키 수 - {!isReorderMode && 순서} - 상태 + 위스키 수 + {!isReorderMode && 순서} + 상태 {isReorderMode && } {isLoading ? ( - + 로딩 중... ) : data?.items.length === 0 ? ( - + 검색 결과가 없습니다. @@ -369,23 +360,7 @@ export function CurationListPage() { {item.displayOrder} )} - - {item.coverImageUrl ? ( - {item.name} - ) : ( -
- -
- )} -
{item.name} - - {truncateDescription(item.description)} - {item.alcoholCount}개 diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 58e2e1d..2590c71 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -67,7 +67,7 @@ export function AppRoutes() { path="whisky/new" element={ - + } /> @@ -75,7 +75,7 @@ export function AppRoutes() { path="whisky/:id" element={ - + } /> @@ -93,7 +93,7 @@ export function AppRoutes() { path="tasting-tags/new" element={ - + } /> @@ -101,7 +101,7 @@ export function AppRoutes() { path="tasting-tags/:id" element={ - + } /> @@ -144,7 +144,7 @@ export function AppRoutes() { path="curations/new" element={ - + } /> @@ -152,7 +152,7 @@ export function AppRoutes() { path="curations/:id" element={ - + } /> diff --git a/src/services/curation.service.ts b/src/services/curation.service.ts index b49ecda..fb1c7fa 100644 --- a/src/services/curation.service.ts +++ b/src/services/curation.service.ts @@ -23,9 +23,7 @@ import { type CurationAddAlcoholsRequest, type CurationAddAlcoholsResponse, type CurationRemoveAlcoholResponse, - type CurationAlcoholItem, } from '@/types/api'; -import { adminAlcoholService } from './admin-alcohol.service'; // ============================================ // Query Keys @@ -48,61 +46,9 @@ export interface CurationListResponse { /** * API 응답을 UI용 CurationDetail로 변환 - * - alcoholIds → alcohols 배열로 변환 (상세 정보 포함) * - modifiedAt → updatedAt 매핑 */ -/** - * 동시성 제한된 병렬 실행 - * @param items - 처리할 아이템 배열 - * @param fn - 각 아이템에 적용할 비동기 함수 - * @param concurrency - 최대 동시 실행 수 (기본값: 5) - */ -async function runWithConcurrency( - items: T[], - fn: (item: T) => Promise, - concurrency = 5 -): Promise { - const results: R[] = []; - const executing: Promise[] = []; - - for (const item of items) { - const promise = fn(item).then((result) => { - results.push(result); - }); - - executing.push(promise as unknown as Promise); - - if (executing.length >= concurrency) { - await Promise.race(executing); - executing.splice(0, executing.findIndex((p) => p === promise) + 1); - } - } - - await Promise.all(executing); - return results; -} - -async function transformDetailResponse(response: CurationDetailResponse): Promise { - // 연결된 위스키 상세 정보 조회 (동시성 제한: 최대 5개) - let alcohols: CurationAlcoholItem[] = []; - - if (response.alcoholIds.length > 0) { - const alcoholDetails = await runWithConcurrency( - response.alcoholIds, - (alcoholId) => adminAlcoholService.getDetail(alcoholId).catch(() => null), - 5 // 최대 5개 동시 요청 - ); - - alcohols = alcoholDetails - .filter((detail): detail is NonNullable => detail !== null) - .map((detail) => ({ - alcoholId: detail.alcoholId, - korName: detail.korName, - engName: detail.engName, - imageUrl: detail.imageUrl, - })); - } - +function transformDetailResponse(response: CurationDetailResponse): CurationDetail { return { id: response.id, name: response.name, @@ -111,9 +57,9 @@ async function transformDetailResponse(response: CurationDetailResponse): Promis displayOrder: response.displayOrder, isActive: response.isActive, createdAt: response.createdAt, - updatedAt: response.modifiedAt, // API는 modifiedAt 사용 - alcoholCount: response.alcoholIds.length, - alcohols, + updatedAt: response.modifiedAt, + alcoholCount: response.alcohols.length, + alcohols: response.alcohols, }; } @@ -146,13 +92,12 @@ export const curationService = { /** * 큐레이션 상세 조회 - * API 응답을 UI용 타입으로 변환하여 반환 - * 연결된 위스키의 상세 정보도 함께 조회 + * API 응답을 UI용 타입으로 변환하여 반환 (연결된 위스키 정보 포함) */ getDetail: async (curationId: number): Promise => { const endpoint = CurationApi.detail.endpoint.replace(':curationId', String(curationId)); const response = await apiClient.get(endpoint); - return await transformDetailResponse(response); + return transformDetailResponse(response); }, /** diff --git a/src/types/api/curation.api.ts b/src/types/api/curation.api.ts index f5d4cce..55689cb 100644 --- a/src/types/api/curation.api.ts +++ b/src/types/api/curation.api.ts @@ -69,8 +69,16 @@ export interface CurationAlcoholItem { korName: string; /** 영문명 */ engName: string; + /** 한글 카테고리명 */ + korCategoryName: string; + /** 영문 카테고리명 */ + engCategoryName: string; /** 위스키 이미지 URL (nullable) */ imageUrl: string | null; + /** 생성일시 */ + createdAt: string; + /** 수정일시 */ + modifiedAt: string; } // ============================================ @@ -146,8 +154,8 @@ export interface CurationApiTypes { createdAt: string; /** 수정일시 */ modifiedAt: string; - /** 포함된 위스키 ID 목록 */ - alcoholIds: number[]; + /** 포함된 위스키 목록 */ + alcohols: CurationAlcoholItem[]; }; }; /** 큐레이션 생성 */