From 92d5b06c1f9383b202a4abcc4cc9ce3512cf8dbd Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Wed, 11 Feb 2026 10:17:11 +0900 Subject: [PATCH 1/5] fix: align curation detail API type with actual response The detail API returns alcohols array with full details, not just alcoholIds. Remove N+1 individual alcohol lookups and use the response directly. Bump version to 1.0.18. Co-Authored-By: Claude Opus 4.6 --- VERSION | 2 +- package.json | 2 +- src/services/curation.service.ts | 64 +++----------------------------- src/types/api/curation.api.ts | 12 +++++- 4 files changed, 17 insertions(+), 63 deletions(-) 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/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/services/curation.service.ts b/src/services/curation.service.ts index b49ecda..d4b9227 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, }; } @@ -152,7 +98,7 @@ export const curationService = { 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[]; }; }; /** 큐레이션 생성 */ From 3303e7f8169d8b27eb8a0663e22a5eaa690177b5 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Wed, 11 Feb 2026 10:47:53 +0900 Subject: [PATCH 2/5] fix: clean up curation list UI and fix E2E tests for required fields - Remove non-existent image/description columns from curation list table - Add table cell padding and adjust column widths for readability - Fix E2E tests to fill all required fields (cover image, whisky) before submit - Add test image fixture and upload/search helpers to page object - Fix ESM __dirname compatibility in curation-detail page object Co-Authored-By: Claude Opus 4.6 --- e2e/fixtures/test-image.png | Bin 0 -> 70 bytes e2e/pages/curation-detail.page.ts | 45 +++++++++++++++++++++++++++ e2e/specs/curations.spec.ts | 18 ++++++++++- src/pages/curations/CurationList.tsx | 39 +++++------------------ 4 files changed, 69 insertions(+), 33 deletions(-) create mode 100644 e2e/fixtures/test-image.png diff --git a/e2e/fixtures/test-image.png b/e2e/fixtures/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2de3749df299a6b84bf6ff1a0b393a1c1fd22b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBTuKYyVd1A7xwz3mB) Q_dp2-Pgg&ebxsLQ0NDZ% 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 업로드 완료 대기 (성공 또는 실패 후 coverImageUrl이 설정됨) + await this.page.waitForTimeout(2000); + } + + /** + * 커버 이미지가 없으면 테스트 이미지 업로드 + */ + 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..da4ac11 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. 커버 이미지 업로드 (필수) + await detailPage.uploadTestImage(); + + // 3-2. 위스키 추가 (필수 - 최소 1개) + const added = await detailPage.addFirstWhiskyBySearch('글렌'); + if (!added) { + test.skip(); + return; + } + // 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/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}개 From c7cfe12843ebf8cd28e9ffe5ce4dde86d94a9038 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Wed, 11 Feb 2026 10:55:19 +0900 Subject: [PATCH 3/5] fix: force remount on new/edit route transitions to reset form state Add key props to detail page components in route definitions to prevent React from reusing component state when navigating between /new and /:id routes. This fixes the tasting tag form not resetting when navigating from a detail page to the create page via sidebar. Co-Authored-By: Claude Opus 4.6 --- src/routes/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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={ - + } /> From 0571ef530d2a8e688262c0f15e7e647477da8eb0 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Wed, 11 Feb 2026 11:00:51 +0900 Subject: [PATCH 4/5] fix: use auto-retrying assertion for form reset E2E test Replace immediate inputValue() check with toHaveValue('') which auto-retries until the React useEffect resets the form, fixing the timing issue between route change and form.reset() execution. Co-Authored-By: Claude Opus 4.6 --- e2e/specs/tasting-tags.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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(''); }); }); From b16a5bc36d998bc0327e24246675b08212e5b742 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Wed, 11 Feb 2026 11:04:10 +0900 Subject: [PATCH 5/5] fix: resolve Copilot review comments (#19) - Replace waitForTimeout(2000) with UI-based wait for upload preview - Reorder whisky add before image upload to avoid S3 side-effect on skip - Fix JSDoc to match actual implementation (no separate alcohol fetching) Co-Authored-By: Claude Opus 4.6 --- e2e/pages/curation-detail.page.ts | 4 ++-- e2e/specs/curations.spec.ts | 8 ++++---- src/services/curation.service.ts | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/e2e/pages/curation-detail.page.ts b/e2e/pages/curation-detail.page.ts index 8be42b5..a3e12c3 100644 --- a/e2e/pages/curation-detail.page.ts +++ b/e2e/pages/curation-detail.page.ts @@ -186,8 +186,8 @@ export class CurationDetailPage extends BasePage { async uploadTestImage() { const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png'); await this.imageFileInput().setInputFiles(testImagePath); - // S3 업로드 완료 대기 (성공 또는 실패 후 coverImageUrl이 설정됨) - await this.page.waitForTimeout(2000); + // S3 업로드 완료 대기: 업로드된 이미지 미리보기가 표시될 때까지 대기 + await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 }); } /** diff --git a/e2e/specs/curations.spec.ts b/e2e/specs/curations.spec.ts index da4ac11..081825a 100644 --- a/e2e/specs/curations.spec.ts +++ b/e2e/specs/curations.spec.ts @@ -215,16 +215,16 @@ test.describe('큐레이션 CRUD 플로우', () => { displayOrder: 999, }); - // 3-1. 커버 이미지 업로드 (필수) - await detailPage.uploadTestImage(); - - // 3-2. 위스키 추가 (필수 - 최소 1개) + // 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( diff --git a/src/services/curation.service.ts b/src/services/curation.service.ts index d4b9227..fb1c7fa 100644 --- a/src/services/curation.service.ts +++ b/src/services/curation.service.ts @@ -92,8 +92,7 @@ export const curationService = { /** * 큐레이션 상세 조회 - * API 응답을 UI용 타입으로 변환하여 반환 - * 연결된 위스키의 상세 정보도 함께 조회 + * API 응답을 UI용 타입으로 변환하여 반환 (연결된 위스키 정보 포함) */ getDetail: async (curationId: number): Promise => { const endpoint = CurationApi.detail.endpoint.replace(':curationId', String(curationId));