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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ coverage/
.nyc_output/
junit.xml

# Playwright
playwright-report/
test-results/
blob-report/

*storybook.log

# Claude Code 파일 기반 텍스트 교환 스크래치 (file-exchange skill)
Expand Down
13 changes: 7 additions & 6 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
"type": "http",
"url": "https://mcp.sentry.dev/mcp"
},
"awslabs.aws-api-mcp-server": {
"aws-mcp": {
"command": "uvx",
"args": ["awslabs.aws-api-mcp-server@latest"],
"env": {
"AWS_REGION": "ap-northeast-2",
"FASTMCP_LOG_LEVEL": "ERROR"
}
"args": [
"mcp-proxy-for-aws@1.6.4",
"https://aws-mcp.us-east-1.api.aws/mcp",
"--profile", "claude-aws-mcp",
"--metadata", "AWS_REGION=ap-northeast-2"
]
},
"kakao-playmcp": {
"type": "http",
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.integration.config.ts",
"test:e2e": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.e2e.config.ts"
"test:e2e": "yarn db:migrate:test && dotenv -e ./.env.test -- vitest run -c vitest.e2e.config.ts",
"start:test": "dotenv -e ./.env.test -- nest start",
"seed:e2e-fixture": "dotenv -e ./.env.test -- ts-node -r tsconfig-paths/register -O '{\"module\":\"CommonJS\"}' src/test/e2e/fixture-seed.script.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.922.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ function formatDateTime(date: Date | null): string {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
// hour12: false만 쓰면 일부 ICU가 자정대(00:00~00:59)를 "24:00~24:59"로 표시한다.
hourCycle: "h23",
}).format(date);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export function getSeoulWallClockForPickup(pickupUtc: Date): {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
// hour12: false만 쓰면 일부 ICU가 자정대(00:00~00:59)를 "24:00~24:59"로 포맷해
// minuteOfDay가 1440대로 계산되는 문제가 있어(getSeoulHour와 동일 사유), hourCycle로 직접 고정한다.
hourCycle: "h23",
weekday: "short",
}).formatToParts(pickupUtc);

Expand Down
155 changes: 155 additions & 0 deletions apps/backend/src/test/e2e/fixture-seed.script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { PrismaClient } from "@apps/backend/infra/database/prisma/generated/client";
import { assertTestDatabase } from "@apps/backend/test/integration/db";

/**
* web-user Playwright E2E(`apps/web-user/e2e`)가 의존하는 최소 픽스처(판매자/스토어/상품)를
* 테스트 DB에 find-or-create로 준비한다. 매번 새로 만들지 않고 마커 필드로 재사용하므로
* 반복 실행해도 데이터가 쌓이지 않는다.
*
* web-user 쪽 테스트는 `GET /v1/consumer/products?search=...`로 아래 상품을 이름 검색해서
* productId를 찾으므로, 이 스크립트와 web-user 테스트 사이에 별도 파일 공유가 필요 없다.
*/

const prisma = new PrismaClient();

const FIXTURE = {
// 실제 휴대폰 번호 형식이 아니라서 실사용자 계정과 겹치지 않음 (REVIEW_LOGIN_ACCOUNT_PHONE_MARKER와 동일 관례)
SELLER_PHONE: "E2E_FIXTURE_SELLER",
STORE: {
// 스토어 이름은 한글/영문/숫자/공백만 허용(validators.decorator.ts)이라 대괄호 마커를 못 씀
NAME: "E2E 테스트 스토어",
BUSINESS_NO: "000-00-E2E01",
PERMISSION_MANAGEMENT_NUMBER: "제E2E-0001호",
ADDRESS: "서울특별시 강남구 역삼동 123-45",
ROAD_ADDRESS: "서울특별시 강남구 테헤란로 1",
DETAIL_ADDRESS: "101호",
ZONECODE: "06234",
LATITUDE: 37.5,
LONGITUDE: 127.03,
},
PRODUCT: {
// web-user e2e가 이 마커로 검색해서 productId를 찾는다.
NAME: "[E2E] 테스트 케이크",
PRICE: 30000,
SIZE_OPTIONS: [
{
id: "e2e-size-mini",
visible: "ENABLE",
displayName: "미니",
lengthCm: 10,
price: 0,
description: "1~2인용",
},
],
FLAVOR_OPTIONS: [
{
id: "e2e-flavor-choco",
visible: "ENABLE",
displayName: "초콜릿",
price: 0,
},
],
// 0이면 레터링 textarea의 maxLength가 0이 되어 아예 입력이 안 되므로 반드시 0보다 커야 함
LETTERING_MAX_LENGTH: 20,
},
};

async function upsertSeller() {
const existing = await prisma.seller.findUnique({ where: { phone: FIXTURE.SELLER_PHONE } });
if (existing) return existing;

return prisma.seller.create({
data: {
phone: FIXTURE.SELLER_PHONE,
name: "E2E 테스트 판매자",
isPhoneVerified: true,
isActive: true,
},
});
}

async function upsertStore(sellerId: string) {
const existing = await prisma.store.findFirst({
where: { sellerId, businessNo: FIXTURE.STORE.BUSINESS_NO },
});
if (existing) return existing;

return prisma.store.create({
data: {
sellerId,
name: FIXTURE.STORE.NAME,
businessNo: FIXTURE.STORE.BUSINESS_NO,
representativeName: "E2E 대표자",
openingDate: "20200101",
businessName: "E2E 테스트 사업자",
businessSector: "제조업",
businessType: "베이커리",
permissionManagementNumber: FIXTURE.STORE.PERMISSION_MANAGEMENT_NUMBER,
// 주문 생성 DTO가 pickupAddress 등을 필수로 요구하므로 채워야 함
address: FIXTURE.STORE.ADDRESS,
roadAddress: FIXTURE.STORE.ROAD_ADDRESS,
detailAddress: FIXTURE.STORE.DETAIL_ADDRESS,
zonecode: FIXTURE.STORE.ZONECODE,
latitude: FIXTURE.STORE.LATITUDE,
longitude: FIXTURE.STORE.LONGITUDE,
// standardOpenTime === standardCloseTime === "00:00" => 하루 전체 영업 (스키마 기본값)
},
});
}

async function upsertProduct(storeId: string) {
const existing = await prisma.product.findFirst({
where: { storeId, name: FIXTURE.PRODUCT.NAME },
});
if (existing) return existing;

return prisma.product.create({
data: {
storeId,
name: FIXTURE.PRODUCT.NAME,
originalPrice: FIXTURE.PRODUCT.PRICE,
salePrice: FIXTURE.PRODUCT.PRICE,
salesStatus: "ENABLE",
visibilityStatus: "ENABLE",
cakeSizeOptions: FIXTURE.PRODUCT.SIZE_OPTIONS,
cakeFlavorOptions: FIXTURE.PRODUCT.FLAVOR_OPTIONS,
letteringVisible: "ENABLE",
letteringMaxLength: FIXTURE.PRODUCT.LETTERING_MAX_LENGTH,
imageUploadEnabled: "DISABLE",
productNumber: `E2E-${Date.now()}`,
productNoticeFoodType: "빵류",
productNoticeProducer: "E2E 테스트 제조사",
productNoticeOrigin: "국내산",
productNoticeAddress: "서울특별시",
productNoticeManufactureDate: "제조일에 별도 표기",
productNoticeExpirationDate: "제조일로부터 3일",
productNoticePackageCapacity: "1개",
productNoticePackageQuantity: "1개",
productNoticeIngredients: "밀가루 외",
productNoticeCalories: "100kcal",
productNoticeSafetyNotice: "-",
productNoticeGmoNotice: "해당없음",
productNoticeImportNotice: "해당없음",
productNoticeCustomerService: "1544-0000",
},
});
}

async function main() {
assertTestDatabase();

const seller = await upsertSeller();
const store = await upsertStore(seller.id);
const product = await upsertProduct(store.id);

console.log(`[e2e-fixture] seller=${seller.id} store=${store.id} product=${product.id}`);
}

main()
.catch((error) => {
console.error("[e2e-fixture] 시딩 실패:", error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
25 changes: 25 additions & 0 deletions apps/web-user/e2e/helpers/review-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, type Page } from "@playwright/test";

/** apps/backend/src/modules/auth/constants/auth.constants.ts의 REVIEW_LOGIN_CODE와 동일 */
export const REVIEW_LOGIN_CODE = "482915";

/**
* 앱스토어 심사 대응 전용 로그인(ReviewLoginBottomSheet)으로 로그인한다.
* `/mypage/version`에서 "앱 버전" 라벨을 10회 연속 탭하면 진입점이 열린다.
*/
export async function openReviewLoginSheet(page: Page) {
await page.goto("/mypage/version");
const versionLabel = page.getByText("앱 버전", { exact: true });
for (let i = 0; i < 10; i++) {
await versionLabel.click();
}
await expect(page.getByText("심사용 로그인")).toBeVisible();
}

/** 심사용 로그인을 완료하고 홈으로 리다이렉트될 때까지 기다린다. */
export async function reviewLogin(page: Page) {
await openReviewLoginSheet(page);
await page.getByPlaceholder("6자리 코드 입력").fill(REVIEW_LOGIN_CODE);
await page.getByRole("button", { name: "로그인" }).click();
await page.waitForURL("/");
}
84 changes: 84 additions & 0 deletions apps/web-user/e2e/reservation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test, type APIRequestContext, type Locator } from "@playwright/test";

import { reviewLogin } from "./helpers/review-login";

const BACKEND_ORIGIN = "http://localhost:3000";
// apps/backend/src/test/e2e/fixture-seed.script.ts가 만드는 상품명과 동일
const FIXTURE_PRODUCT_NAME = "[E2E] 테스트 케이크";

async function findFixtureProductId(request: APIRequestContext) {
const res = await request.get(`${BACKEND_ORIGIN}/v1/consumer/products`, {
params: { sortBy: "popular", page: 1, limit: 20, search: FIXTURE_PRODUCT_NAME },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
const product = body.data.data.find((p: { name: string }) => p.name === FIXTURE_PRODUCT_NAME);
expect(product, "fixture-seed.script.ts를 먼저 실행해 시드 상품이 있어야 합니다").toBeTruthy();
return product.id as string;
}

/**
* 사이즈/맛 Select는 픽업날짜·사이즈를 고르면 다음 단계 Select가 자동으로 열리는
* UX가 있어(ReservationOptionsView의 openSignal 이펙트) 이미 열려 있을 수도, 아닐 수도 있다.
* 열려 있지 않을 때만 트리거를 클릭해 두 경우 모두 안전하게 처리한다.
*/
async function selectDropdownOption(sheet: Locator, triggerLabel: string, optionText: string) {
const option = sheet.getByRole("listitem").filter({ hasText: optionText });
// 자동으로 열리는 경우 렌더링이 이 시점보다 늦게 반영될 수 있어, 짧게 기다렸다가
// 그래도 안 열려 있으면 직접 트리거를 클릭한다(이미 열려 있는데 또 클릭하면 토글되어 닫혀버림).
const alreadyOpen = await option
.waitFor({ state: "visible", timeout: 1000 })
.then(() => true)
.catch(() => false);
if (!alreadyOpen) {
await sheet.getByRole("button", { name: triggerLabel }).click();
}
await option.click();
}

test("예약(주문) 생성 골든패스", async ({ page, request }) => {
await reviewLogin(page);

const productId = await findFixtureProductId(request);
await page.goto(`/product/${productId}`);

await page.getByRole("button", { name: "예약하기" }).click();

// BottomSheet는 열려 있을 때만 마운트되는 단일 인스턴스라, 이 안에서만 찾으면
// 상품 상세 페이지 하단에 그대로 남아있는 "예약하기" CTA와 헷갈리지 않는다.
const sheet = page.locator(".animate-slide-up");
await expect(sheet.getByText("상품 옵션 선택")).toBeVisible();

// 픽업날짜: 캘린더로 진입해 "내일"을 명시적으로 선택
// (당일 선택 시 심야 실행이면 남은 시간 슬롯이 없을 수 있음)
await sheet.getByRole("button", { name: "픽업할 날짜와 시간을 선택해주세요" }).click();
await expect(sheet.getByText("날짜 선택")).toBeVisible();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await sheet.getByRole("button", { name: String(tomorrow.getDate()), exact: true }).click();
await sheet
.getByRole("button", { name: /^(오전|오후) \d{1,2}:\d{2}$/ })
.first()
.click();
await sheet.getByRole("button", { name: "선택완료" }).click();

// 사이즈/맛 선택 (fixture-seed.script.ts가 심는 옵션과 동일 라벨)
await selectDropdownOption(sheet, "사이즈 선택", "미니");
await selectDropdownOption(sheet, "맛 선택", "초콜릿");

await sheet.getByPlaceholder("가능한 10자 이내로 적어주세요.").fill("생일 축하해");

await sheet.getByRole("button", { name: "선택완료" }).click();
await expect(sheet.getByText("주문확인")).toBeVisible();

// 심사용 계정 프로필의 전화번호(REVIEW_LOGIN_ACCOUNT_PHONE_MARKER)는 실제 휴대폰 형식이
// 아니라서 prefill 여부와 무관하게 유효한 값으로 덮어써야 한다.
await sheet.getByPlaceholder("예약자명을 입력해주세요.").fill("테스트예약자");
await sheet.getByPlaceholder("휴대폰 번호를 입력해주세요.").fill("01099998888");
// Checkbox의 실제 input은 sr-only(시각적으로 숨김)라 label 텍스트를 클릭해 토글한다
await sheet.getByText("모두 동의합니다", { exact: true }).click();

await sheet.getByRole("button", { name: "예약하기" }).click();

await page.waitForURL(/\/reservation\/complete\?orderId=.+/);
});
24 changes: 24 additions & 0 deletions apps/web-user/e2e/review-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";

import { openReviewLoginSheet, reviewLogin } from "./helpers/review-login";

test.describe("심사용 로그인", () => {
test("올바른 코드를 입력하면 홈으로 이동하고 로그인 상태가 저장된다", async ({ page }) => {
await reviewLogin(page);

await expect(page).toHaveURL("/");
const authState = await page.evaluate(() => localStorage.getItem("picake:auth"));
expect(authState).not.toBeNull();
expect(JSON.parse(authState!).state.isAuthenticated).toBe(true);
});

test("잘못된 코드를 입력하면 오류가 표시되고 로그인되지 않는다", async ({ page }) => {
await openReviewLoginSheet(page);

await page.getByPlaceholder("6자리 코드 입력").fill("000000");
await page.getByRole("button", { name: "로그인" }).click();

await expect(page.getByText("오류")).toBeVisible();
await expect(page).toHaveURL(/\/mypage\/version$/);
});
});
6 changes: 4 additions & 2 deletions apps/web-user/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"test": "vitest run",
"test:watch": "vitest --project=unit",
"test:unit": "vitest run --project=unit",
"test:storybook": "vitest run --project=storybook"
"test:storybook": "vitest run --project=storybook",
"test:e2e": "playwright test"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
Expand All @@ -41,6 +42,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@playwright/test": "^1.62.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6",
Expand All @@ -56,7 +58,7 @@
"@vitest/coverage-v8": "^4.1.9",
"autoprefixer": "^10.4.22",
"eslint-plugin-storybook": "^10.4.6",
"playwright": "^1.57.0",
"playwright": "^1.62.1",
"postcss": "^8.5.10",
"storybook": "^10.4.6",
"tailwindcss": "^3",
Expand Down
Loading
Loading