Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.17
1.0.18
Binary file added e2e/fixtures/test-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions e2e/pages/curation-detail.page.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand Down Expand Up @@ -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<boolean> {
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 응답 대기)
*/
Expand Down
18 changes: 17 additions & 1 deletion e2e/specs/curations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -386,6 +396,9 @@ test.describe('큐레이션 위스키 관리', () => {
await listPage.clickFirstRow();
await detailPage.waitForLoadingComplete();

// 커버 이미지 없으면 업로드 (필수 필드)
await detailPage.ensureCoverImage();

// 현재 위스키 개수 확인
const initialCount = await detailPage.getWhiskyCount();

Expand Down Expand Up @@ -442,6 +455,9 @@ test.describe('큐레이션 위스키 관리', () => {
await listPage.clickFirstRow();
await detailPage.waitForLoadingComplete();

// 커버 이미지 없으면 업로드 (필수 필드)
await detailPage.ensureCoverImage();

// 현재 위스키 개수 확인
let currentCount = await detailPage.getWhiskyCount();

Expand Down
5 changes: 2 additions & 3 deletions e2e/specs/tasting-tags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
});
});

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
39 changes: 7 additions & 32 deletions src/pages/curations/CurationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
{/* 헤더 */}
Expand Down Expand Up @@ -316,29 +309,27 @@ export function CurationListPage() {

{/* 테이블 */}
<div className="rounded-lg border">
<Table>
<Table className="[&_th]:px-4 [&_td]:px-4">
<TableHeader>
<TableRow>
{isReorderMode && <TableHead className="w-[60px]">순서</TableHead>}
<TableHead className="w-[80px]">이미지</TableHead>
<TableHead>큐레이션명</TableHead>
<TableHead className="w-[200px]">설명</TableHead>
<TableHead className="w-[100px]">위스키 수</TableHead>
{!isReorderMode && <TableHead className="w-[60px]">순서</TableHead>}
<TableHead className="w-[100px]">상태</TableHead>
<TableHead className="w-[300px]">위스키 수</TableHead>
{!isReorderMode && <TableHead className="w-[180px]">순서</TableHead>}
<TableHead className="w-[300px]">상태</TableHead>
{isReorderMode && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={isReorderMode ? 7 : 6} className="text-center py-8">
<TableCell colSpan={isReorderMode ? 5 : 4} className="text-center py-8">
<span className="text-muted-foreground">로딩 중...</span>
</TableCell>
</TableRow>
) : data?.items.length === 0 ? (
<TableRow>
<TableCell colSpan={isReorderMode ? 7 : 6} className="text-center py-8">
<TableCell colSpan={isReorderMode ? 5 : 4} className="text-center py-8">
<span className="text-muted-foreground">
검색 결과가 없습니다.
</span>
Expand Down Expand Up @@ -369,23 +360,7 @@ export function CurationListPage() {
{item.displayOrder}
</TableCell>
)}
<TableCell>
{item.coverImageUrl ? (
<img
src={item.coverImageUrl}
alt={item.name}
className="h-10 w-16 rounded object-cover"
/>
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-muted">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</TableCell>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{truncateDescription(item.description)}
</TableCell>
<TableCell>
<Badge variant="outline">
{item.alcoholCount}개
Expand Down
12 changes: 6 additions & 6 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ export function AppRoutes() {
path="whisky/new"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<WhiskyDetailPage />
<WhiskyDetailPage key="new" />
</RoleProtectedRoute>
}
/>
<Route
path="whisky/:id"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<WhiskyDetailPage />
<WhiskyDetailPage key="edit" />
</RoleProtectedRoute>
}
/>
Expand All @@ -93,15 +93,15 @@ export function AppRoutes() {
path="tasting-tags/new"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<TastingTagDetailPage />
<TastingTagDetailPage key="new" />
</RoleProtectedRoute>
}
/>
<Route
path="tasting-tags/:id"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<TastingTagDetailPage />
<TastingTagDetailPage key="edit" />
</RoleProtectedRoute>
}
/>
Expand Down Expand Up @@ -144,15 +144,15 @@ export function AppRoutes() {
path="curations/new"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<CurationDetailPage />
<CurationDetailPage key="new" />
</RoleProtectedRoute>
}
/>
<Route
path="curations/:id"
element={
<RoleProtectedRoute roles={['ROOT_ADMIN']}>
<CurationDetailPage />
<CurationDetailPage key="edit" />
</RoleProtectedRoute>
}
/>
Expand Down
67 changes: 6 additions & 61 deletions src/services/curation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ import {
type CurationAddAlcoholsRequest,
type CurationAddAlcoholsResponse,
type CurationRemoveAlcoholResponse,
type CurationAlcoholItem,
} from '@/types/api';
import { adminAlcoholService } from './admin-alcohol.service';

// ============================================
// Query Keys
Expand All @@ -48,61 +46,9 @@ export interface CurationListResponse {

/**
* API 응답을 UI용 CurationDetail로 변환
* - alcoholIds → alcohols 배열로 변환 (상세 정보 포함)
* - modifiedAt → updatedAt 매핑
*/
/**
* 동시성 제한된 병렬 실행
* @param items - 처리할 아이템 배열
* @param fn - 각 아이템에 적용할 비동기 함수
* @param concurrency - 최대 동시 실행 수 (기본값: 5)
*/
async function runWithConcurrency<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency = 5
): Promise<R[]> {
const results: R[] = [];
const executing: Promise<void>[] = [];

for (const item of items) {
const promise = fn(item).then((result) => {
results.push(result);
});

executing.push(promise as unknown as Promise<void>);

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<CurationDetail> {
// 연결된 위스키 상세 정보 조회 (동시성 제한: 최대 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<typeof detail> => 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,
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -146,13 +92,12 @@ export const curationService = {

/**
* 큐레이션 상세 조회
* API 응답을 UI용 타입으로 변환하여 반환
* 연결된 위스키의 상세 정보도 함께 조회
* API 응답을 UI용 타입으로 변환하여 반환 (연결된 위스키 정보 포함)
*/
getDetail: async (curationId: number): Promise<CurationDetail> => {
const endpoint = CurationApi.detail.endpoint.replace(':curationId', String(curationId));
const response = await apiClient.get<CurationDetailResponse>(endpoint);
return await transformDetailResponse(response);
return transformDetailResponse(response);
},

/**
Expand Down
12 changes: 10 additions & 2 deletions src/types/api/curation.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,16 @@ export interface CurationAlcoholItem {
korName: string;
/** 영문명 */
engName: string;
/** 한글 카테고리명 */
korCategoryName: string;
/** 영문 카테고리명 */
engCategoryName: string;
/** 위스키 이미지 URL (nullable) */
imageUrl: string | null;
/** 생성일시 */
createdAt: string;
/** 수정일시 */
modifiedAt: string;
}

// ============================================
Expand Down Expand Up @@ -146,8 +154,8 @@ export interface CurationApiTypes {
createdAt: string;
/** 수정일시 */
modifiedAt: string;
/** 포함된 위스키 ID 목록 */
alcoholIds: number[];
/** 포함된 위스키 목록 */
alcohols: CurationAlcoholItem[];
};
};
/** 큐레이션 생성 */
Expand Down