From c00a7ba53abb2167c864239fae8e61be30cd519f Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 6 May 2026 04:22:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B2=80=EC=A6=9D=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 개발자가 로컬에서 마이페이지 API 전반을 시각적으로 검증할 수 있도록 prisma db seed 명령으로 한 번에 채울 수 있는 테스트 데이터 인프라를 도입한다. ## 구현 - package.json - scripts.prisma:seed - prisma.seed: ts-node + tsconfig-paths 기반 entry - prisma/seed.ts: entry point (production 가드 포함) - prisma/seed/idempotent.ts: 시드 영역(seed-user-* / [SEED] *) 정리 헬퍼 - prisma/seed/{users,stores,orders,reviews,wishlist,recent-views, notifications,custom-drafts,search-history}.ts ## 시드 시나리오 마이페이지 API 거의 모든 분기를 한 번에 검증할 수 있도록 구성: - 유저 2명 (온보딩 완료 / 미완료) - 매장 2개 + 상품 5개 (sale_price 있음/없음, 비활성 1) - 주문 6건 (각 status + PICKED_UP에 리뷰 작성/미작성 둘 다) - o2에 selectedOptions/customTexts/customFreeEdits 포함 - 리뷰 1건 (IMAGE+VIDEO 미디어 포함) - 찜 3건 (visible 2 + invisible 1로 visibleWishlistWhere 검증) - 최근 본 상품 4건 - 알림 3건 (unread 2 / read 1) - 커스텀 드래프트 2건 (IN_PROGRESS / READY_FOR_ORDER) - 검색 히스토리 3건 ## 부수 변경 docker-compose.yml과 docker/mysql/init/01-grant-shadow-db.sql: - prisma client 6.x가 일부 환경에서 caching_sha2_password를 sha256_password로 잘못 인식하는 호환 이슈 회피. - mysql_native_password로 통일 (default-authentication-plugin 옵션 + ALTER USER 이중 안전망). README.md: 테스트 데이터 시드 사용법 섹션 추가. ## 검증 - 첫 실행 / 두 번째 실행 모두 성공 (idempotent) - NODE_ENV=production 차단 동작 확인 - npx tsc --noEmit 통과 --- README.md | 17 ++ docker-compose.yml | 7 + docker/mysql/init/01-grant-shadow-db.sql | 6 + package.json | 4 + prisma/seed.ts | 82 ++++++ prisma/seed/custom-drafts.ts | 31 ++ prisma/seed/idempotent.ts | 262 +++++++++++++++++ prisma/seed/notifications.ts | 49 +++ prisma/seed/orders.ts | 360 +++++++++++++++++++++++ prisma/seed/recent-views.ts | 45 +++ prisma/seed/reviews.ts | 48 +++ prisma/seed/search-history.ts | 37 +++ prisma/seed/stores.ts | 187 ++++++++++++ prisma/seed/users.ts | 58 ++++ prisma/seed/wishlist.ts | 30 ++ 15 files changed, 1223 insertions(+) create mode 100644 prisma/seed.ts create mode 100644 prisma/seed/custom-drafts.ts create mode 100644 prisma/seed/idempotent.ts create mode 100644 prisma/seed/notifications.ts create mode 100644 prisma/seed/orders.ts create mode 100644 prisma/seed/recent-views.ts create mode 100644 prisma/seed/reviews.ts create mode 100644 prisma/seed/search-history.ts create mode 100644 prisma/seed/stores.ts create mode 100644 prisma/seed/users.ts create mode 100644 prisma/seed/wishlist.ts diff --git a/README.md b/README.md index da24eaa..9f909c7 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,23 @@ $ yarn run test:e2e $ yarn run test:cov ``` +## 테스트 데이터 시드 (로컬 한정) + +마이페이지 API를 시각적으로 검증하기 위한 테스트 데이터를 한 번에 채울 수 있습니다. + +```bash +# 사전: docker compose up -d 로 MySQL 컨테이너가 떠 있어야 함 +# 사전: yarn prisma:migrate:deploy (또는 :dev) 로 마이그레이션이 적용되어 있어야 함 + +$ yarn prisma:seed +``` + +- 실행 시 기존 시드 영역(`seed-user-*` 이메일, `[SEED] *` 매장명)을 정리한 뒤 재삽입합니다 (idempotent). +- 발급된 테스트 `accountId`가 콘솔에 출력됩니다 — Dev 토큰 발급 헬퍼와 함께 사용하세요. +- `NODE_ENV=production` 에서는 자동으로 차단됩니다. + +자세한 시드 시나리오와 검증 가능한 API 매핑은 노션의 `마이페이지 API 가이드` 를 참고하세요. + ## Deployment When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. diff --git a/docker-compose.yml b/docker-compose.yml index 444c56a..b62f95b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,13 @@ services: mysql: image: mysql:8.0 container_name: caquick-mysql + # MySQL 8 기본 caching_sha2_password 가 prisma client(현 6.x)에서 + # 일부 환경에서 sha256_password 로 잘못 인식되어 연결이 실패하는 케이스가 + # 있어 mysql_native_password 로 통일한다. 새로 생성되는 사용자도 동일 plugin. + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: CaQuick diff --git a/docker/mysql/init/01-grant-shadow-db.sql b/docker/mysql/init/01-grant-shadow-db.sql index b77f3d1..efbb3ff 100644 --- a/docker/mysql/init/01-grant-shadow-db.sql +++ b/docker/mysql/init/01-grant-shadow-db.sql @@ -1,4 +1,10 @@ -- Prisma migrate dev는 shadow database를 임시 생성/삭제하므로 -- caquick 유저에게 DB 생성 권한을 부여한다. GRANT ALL PRIVILEGES ON *.* TO 'caquick'@'%'; + +-- prisma client 6.x가 일부 환경에서 caching_sha2_password를 +-- sha256_password로 잘못 인식하는 호환 이슈가 있어 native_password로 통일. +-- (docker-compose의 --default-authentication-plugin과 이중 안전망) +ALTER USER 'caquick'@'%' IDENTIFIED WITH mysql_native_password BY 'caquick'; + FLUSH PRIVILEGES; diff --git a/package.json b/package.json index a886272..ae33b43 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,14 @@ "prisma:migrate:create": "prisma migrate dev --create-only", "prisma:migrate:deploy": "prisma migrate deploy", "prisma:studio": "prisma studio", + "prisma:seed": "prisma db seed", "graphql:codegen": "graphql-codegen --config codegen.yml", "graphql:docs": "spectaql -c spectaql.yml", "prepare": "husky" }, + "prisma": { + "seed": "ts-node -r tsconfig-paths/register prisma/seed.ts" + }, "dependencies": { "@apollo/server": "^5.5.0", "@as-integrations/express5": "^1.1.2", diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..590547d --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,82 @@ +/** + * 마이페이지 검증용 시드 데이터. + * + * 실행: yarn prisma:seed + * + * - 기존 시드 영역(seed-user-* 이메일, [SEED] * 매장명)을 정리한 뒤 재삽입한다 (idempotent). + * - production 환경에서는 자동 차단된다. + * + * 발급된 테스트 accountId가 콘솔에 출력되며, GraphQL Playground에서 dev 토큰 + * 발급 헬퍼(POST /auth/dev/issue-token)와 함께 사용한다. + */ +import { PrismaClient } from '@prisma/client'; + +import { seedCustomDrafts } from './seed/custom-drafts'; +import { resetSeedScope } from './seed/idempotent'; +import { seedNotifications } from './seed/notifications'; +import { seedOrders } from './seed/orders'; +import { seedRecentViews } from './seed/recent-views'; +import { seedReviews } from './seed/reviews'; +import { seedSearchHistory } from './seed/search-history'; +import { seedStores } from './seed/stores'; +import { seedUsers } from './seed/users'; +import { seedWishlist } from './seed/wishlist'; + +async function main(): Promise { + if (process.env.NODE_ENV === 'production') { + throw new Error('seed는 production 환경에서 실행할 수 없습니다.'); + } + + const prisma = new PrismaClient(); + try { + log('기존 시드 영역 정리 중...'); + await resetSeedScope(prisma); + + log('유저 + 프로필 시드 중...'); + const users = await seedUsers(prisma); + + log('매장 + 상품 시드 중...'); + const stores = await seedStores(prisma); + + log('주문 + 아이템 시드 중...'); + const orders = await seedOrders(prisma, { users, stores }); + + log('리뷰 시드 중...'); + await seedReviews(prisma, { users, stores, orders }); + + log('찜 시드 중...'); + await seedWishlist(prisma, { users, stores }); + + log('최근 본 상품 시드 중...'); + await seedRecentViews(prisma, { users, stores }); + + log('알림 시드 중...'); + await seedNotifications(prisma, { users }); + + log('커스텀 드래프트 시드 중...'); + await seedCustomDrafts(prisma, { users, stores }); + + log('검색 히스토리 시드 중...'); + await seedSearchHistory(prisma, { users }); + + log('완료. 발급된 테스트 계정:'); + for (const u of users) { + const status = + u.name === null + ? '온보딩 미완료 (needsProfile=true 검증용)' + : '온보딩 완료'; + log(` - accountId=${u.id.toString()} email=${u.email} (${status})`); + } + } finally { + await prisma.$disconnect(); + } +} + +function log(message: string): void { + console.log(`[seed] ${message}`); +} + +main().catch((err: unknown) => { + console.error('[seed] 실패:', err); + process.exit(1); +}); diff --git a/prisma/seed/custom-drafts.ts b/prisma/seed/custom-drafts.ts new file mode 100644 index 0000000..96f4cc3 --- /dev/null +++ b/prisma/seed/custom-drafts.ts @@ -0,0 +1,31 @@ +/** + * 시드 커스텀 드래프트 (user1, 2건). customDraftCount=2. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedCustomDrafts( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedCustomDrafts'); + const [p1, , p3] = ctx.stores.products; + + await prisma.customDraft.createMany({ + data: [ + { + account_id: user1.id, + product_id: p1.id, + status: 'IN_PROGRESS', + }, + { + account_id: user1.id, + product_id: p3.id, + status: 'READY_FOR_ORDER', + }, + ], + }); +} diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts new file mode 100644 index 0000000..aac045a --- /dev/null +++ b/prisma/seed/idempotent.ts @@ -0,0 +1,262 @@ +/** + * 시드 데이터 정리(idempotent) 헬퍼. + * + * 시드는 다음 식별자들로만 자기 영역을 구분한다: + * - 유저 이메일: SEED_USER_EMAIL_PREFIX (`seed-user-`) + * - 매장 이름: SEED_STORE_NAME_PREFIX (`[SEED] `) + * + * 정리 시 위 prefix에 매칭되는 row와 그 종속 데이터(주문/리뷰/찜/...)를 + * 삭제한 뒤 다시 삽입하므로, 수동으로 만든 다른 데이터는 보존된다. + */ +import type { PrismaClient } from '@prisma/client'; + +export const SEED_USER_EMAIL_PREFIX = 'seed-user-'; +export const SEED_STORE_NAME_PREFIX = '[SEED] '; + +export async function resetSeedScope(prisma: PrismaClient): Promise { + const seedUsers = await prisma.account.findMany({ + where: { email: { startsWith: SEED_USER_EMAIL_PREFIX } }, + select: { id: true }, + }); + const userIds = seedUsers.map((u) => u.id); + + const seedStores = await prisma.store.findMany({ + where: { store_name: { startsWith: SEED_STORE_NAME_PREFIX } }, + select: { id: true, seller_account_id: true }, + }); + const storeIds = seedStores.map((s) => s.id); + const sellerAccountIds = seedStores.map((s) => s.seller_account_id); + + // 1) 유저 종속 (주문, 리뷰, 찜, 최근본, 알림, 드래프트, 검색기록, 카트, 인증세션) + if (userIds.length > 0) { + // 주문 종속들 → 주문 본체 (FK depth가 깊으므로 안에서 다시 처리) + const userOrders = await prisma.order.findMany({ + where: { account_id: { in: userIds } }, + select: { id: true }, + }); + const orderIds = userOrders.map((o) => o.id); + if (orderIds.length > 0) { + const orderItems = await prisma.orderItem.findMany({ + where: { order_id: { in: orderIds } }, + select: { id: true }, + }); + const orderItemIds = orderItems.map((i) => i.id); + if (orderItemIds.length > 0) { + // OrderItemCustomFreeEdit -> attachments -> deletes + const freeEdits = await prisma.orderItemCustomFreeEdit.findMany({ + where: { order_item_id: { in: orderItemIds } }, + select: { id: true }, + }); + const freeEditIds = freeEdits.map((f) => f.id); + if (freeEditIds.length > 0) { + await prisma.orderItemCustomFreeEditAttachment.deleteMany({ + where: { free_edit_id: { in: freeEditIds } }, + }); + } + await prisma.orderItemCustomFreeEdit.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + await prisma.orderItemCustomText.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + await prisma.orderItemOptionItem.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + // 리뷰 (OrderItem fk + Review fk) + await prisma.reviewLike.deleteMany({ + where: { review: { order_item_id: { in: orderItemIds } } }, + }); + await prisma.reviewMedia.deleteMany({ + where: { review: { order_item_id: { in: orderItemIds } } }, + }); + await prisma.review.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + } + await prisma.orderItem.deleteMany({ + where: { order_id: { in: orderIds } }, + }); + await prisma.orderStatusHistory.deleteMany({ + where: { order_id: { in: orderIds } }, + }); + await prisma.order.deleteMany({ where: { id: { in: orderIds } } }); + } + + // 리뷰: 주문 종속에서 이미 처리되었지만, 다른 리뷰가 있을 수 있음 → 사용자 기준으로도 한 번 더 + await prisma.reviewLike.deleteMany({ + where: { account_id: { in: userIds } }, + }); + await prisma.reviewMedia.deleteMany({ + where: { review: { account_id: { in: userIds } } }, + }); + await prisma.review.deleteMany({ where: { account_id: { in: userIds } } }); + + // 찜 + await prisma.wishlistItem.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 최근 본 + await prisma.recentProductView.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 알림 + await prisma.notification.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 커스텀 드래프트 + await prisma.customDraft.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 검색 히스토리 + await prisma.searchHistory.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 카트 + const userCarts = await prisma.cart.findMany({ + where: { account_id: { in: userIds } }, + select: { id: true }, + }); + const cartIds = userCarts.map((c) => c.id); + if (cartIds.length > 0) { + const cartItems = await prisma.cartItem.findMany({ + where: { cart_id: { in: cartIds } }, + select: { id: true }, + }); + const cartItemIds = cartItems.map((i) => i.id); + if (cartItemIds.length > 0) { + await prisma.cartItemOptionItem.deleteMany({ + where: { cart_item_id: { in: cartItemIds } }, + }); + } + await prisma.cartItem.deleteMany({ where: { cart_id: { in: cartIds } } }); + await prisma.cart.deleteMany({ where: { id: { in: cartIds } } }); + } + + // 인증 세션 + await prisma.authRefreshSession.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 계정 ID 연결 해제 + await prisma.accountIdentity.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 프로필 + await prisma.userProfile.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 계정 본체 + await prisma.account.deleteMany({ where: { id: { in: userIds } } }); + } + + // 2) 매장 종속 (상품, 영업시간 등) + if (storeIds.length > 0) { + const storeProducts = await prisma.product.findMany({ + where: { store_id: { in: storeIds } }, + select: { id: true }, + }); + const productIds = storeProducts.map((p) => p.id); + if (productIds.length > 0) { + // 옵션 + const optionGroups = await prisma.productOptionGroup.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const optionGroupIds = optionGroups.map((g) => g.id); + if (optionGroupIds.length > 0) { + await prisma.productOptionItem.deleteMany({ + where: { option_group_id: { in: optionGroupIds } }, + }); + } + await prisma.productOptionGroup.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + // 커스텀 템플릿 + const templates = await prisma.productCustomTemplate.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const templateIds = templates.map((t) => t.id); + if (templateIds.length > 0) { + await prisma.productCustomTextToken.deleteMany({ + where: { template_id: { in: templateIds } }, + }); + } + await prisma.productCustomTemplate.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + await prisma.productImage.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.productCategory.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.productTag.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + // 다른 사용자의 cartItem/wishlist/recentView/customDraft에 이 product가 참조될 수 있음 + // 시드 store의 product를 안전하게 지우려면 외부 참조도 같이 정리 + const sharedItems = await prisma.cartItem.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const sharedItemIds = sharedItems.map((i) => i.id); + if (sharedItemIds.length > 0) { + await prisma.cartItemOptionItem.deleteMany({ + where: { cart_item_id: { in: sharedItemIds } }, + }); + } + await prisma.cartItem.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.wishlistItem.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.recentProductView.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.customDraft.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + await prisma.product.deleteMany({ where: { id: { in: productIds } } }); + } + + await prisma.storeBusinessHour.deleteMany({ + where: { store_id: { in: storeIds } }, + }); + await prisma.storeSpecialClosure.deleteMany({ + where: { store_id: { in: storeIds } }, + }); + await prisma.store.deleteMany({ where: { id: { in: storeIds } } }); + } + + // 3) 매장의 seller account 본체 + if (sellerAccountIds.length > 0) { + await prisma.sellerCredential.deleteMany({ + where: { seller_account_id: { in: sellerAccountIds } }, + }); + await prisma.sellerProfile.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.accountIdentity.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.authRefreshSession.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.account.deleteMany({ + where: { id: { in: sellerAccountIds } }, + }); + } +} diff --git a/prisma/seed/notifications.ts b/prisma/seed/notifications.ts new file mode 100644 index 0000000..52d58fc --- /dev/null +++ b/prisma/seed/notifications.ts @@ -0,0 +1,49 @@ +/** + * 시드 알림 (user1, 3건). 읽음 1 / 안읽음 2 → unreadNotificationCount=2. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +export async function seedNotifications( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedNotifications'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + + await prisma.notification.createMany({ + data: [ + { + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_CONFIRMED', + title: '주문이 확정되었습니다', + body: 'SEED-O2-CONF 주문이 확정되었습니다.', + read_at: null, + created_at: new Date(now - 1 * hour), + }, + { + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_MADE', + title: '주문이 제작 완료되었습니다', + body: 'SEED-O3-MADE 주문의 상품 제작이 완료되었습니다.', + read_at: null, + created_at: new Date(now - 24 * hour), + }, + { + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_PICKED_UP', + title: '주문이 픽업 처리되었습니다', + body: 'SEED-O4-PICKED-RE 주문이 픽업 완료 처리되었습니다.', + read_at: new Date(now - 9 * 24 * hour), + created_at: new Date(now - 10 * 24 * hour), + }, + ], + }); +} diff --git a/prisma/seed/orders.ts b/prisma/seed/orders.ts new file mode 100644 index 0000000..b92fe25 --- /dev/null +++ b/prisma/seed/orders.ts @@ -0,0 +1,360 @@ +/** + * 시드 주문 + 아이템 + 상태 히스토리. + * + * user1 기준 6건: + * o1 SUBMITTED - p1×1 + * o2 CONFIRMED - p2×1 + selectedOptions(레귤러) + * o3 MADE - p3×2 + * o4 PICKED_UP - p1×1 (리뷰 작성됨 → hasReviewableItem=false, hasMyReview=true) + * o5 PICKED_UP - p4×1 (리뷰 미작성 → hasReviewableItem=true) + * o6 CANCELED - p2×1 + */ +import type { OrderStatus, PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export interface SeededOrders { + o1Submitted: bigint; + o2Confirmed: bigint; + o3Made: bigint; + o4PickedUpReviewed: bigint; + o4OrderItemId: bigint; + o5PickedUpReviewable: bigint; + o6Canceled: bigint; +} + +export async function seedOrders( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedOrders'); + const [storeA, storeB] = ctx.stores.stores; + const [p1, p2, p3, p4] = ctx.stores.products; + const { p2GroupId, p2OptionItemId } = ctx.stores.optionGroupIds; + + const day = 24 * 60 * 60 * 1000; + const now = new Date(); + const buyerName = user1.name ?? '테스트 유저'; + const buyerPhone = '010-1111-2222'; + + // ── o1: SUBMITTED (방금 주문) ── + const o1 = await prisma.order.create({ + data: { + order_number: 'SEED-O1-SUB', + account_id: user1.id, + status: 'SUBMITTED', + pickup_at: new Date(now.getTime() + 3 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 35000, + discount_price: 0, + total_price: 35000, + submitted_at: now, + items: { + create: [ + { + store_id: storeA.id, + product_id: p1.id, + product_name_snapshot: p1.name, + regular_price_snapshot: p1.regular_price, + sale_price_snapshot: p1.sale_price, + quantity: 1, + item_subtotal_price: 35000, + }, + ], + }, + status_histories: { + create: [ + { from_status: null, to_status: 'SUBMITTED', changed_at: now }, + ], + }, + }, + }); + + // ── o2: CONFIRMED (어제 확정) ── + const o2SubmittedAt = new Date(now.getTime() - 2 * day); + const o2ConfirmedAt = new Date(now.getTime() - 1 * day); + const o2 = await prisma.order.create({ + data: { + order_number: 'SEED-O2-CONF', + account_id: user1.id, + status: 'CONFIRMED', + pickup_at: new Date(now.getTime() + 4 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 60000, + discount_price: 0, + total_price: 60000, + submitted_at: o2SubmittedAt, + confirmed_at: o2ConfirmedAt, + status_histories: { + create: [ + { + from_status: null, + to_status: 'SUBMITTED', + changed_at: o2SubmittedAt, + }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o2ConfirmedAt, + }, + ], + }, + }, + }); + const o2Item = await prisma.orderItem.create({ + data: { + order_id: o2.id, + store_id: storeA.id, + product_id: p2.id, + product_name_snapshot: p2.name, + regular_price_snapshot: p2.regular_price, + sale_price_snapshot: p2.sale_price, + quantity: 1, + item_subtotal_price: 60000, + }, + }); + await prisma.orderItemOptionItem.create({ + data: { + order_item_id: o2Item.id, + option_group_id: p2GroupId, + option_item_id: p2OptionItemId, + group_name_snapshot: '케이크 사이즈', + option_title_snapshot: '레귤러 (6호)', + option_price_delta_snapshot: 10000, + }, + }); + // 커스텀 텍스트 1건 + 자유 편집 1건 (주문 상세 검증용) + await prisma.orderItemCustomText.create({ + data: { + order_item_id: o2Item.id, + token_key_snapshot: 'message', + default_text_snapshot: '메시지를 입력하세요', + value_text: '생일 축하해 :)', + sort_order: 0, + }, + }); + const o2FreeEdit = await prisma.orderItemCustomFreeEdit.create({ + data: { + order_item_id: o2Item.id, + crop_image_url: 'https://placehold.co/300x300/png?text=Custom+Crop', + description_text: '이 사진을 케이크 위에 그려주세요', + sort_order: 0, + }, + }); + await prisma.orderItemCustomFreeEditAttachment.create({ + data: { + free_edit_id: o2FreeEdit.id, + image_url: 'https://placehold.co/300x300/png?text=Reference', + sort_order: 0, + }, + }); + + // ── o3: MADE (제작 완료, 픽업 임박) ── + const o3Submitted = new Date(now.getTime() - 4 * day); + const o3Confirmed = new Date(now.getTime() - 3 * day); + const o3Made = new Date(now.getTime() - 1 * day); + const o3 = await prisma.order.create({ + data: { + order_number: 'SEED-O3-MADE', + account_id: user1.id, + status: 'MADE', + pickup_at: new Date(now.getTime() + 1 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 50000, + discount_price: 6000, + total_price: 44000, + submitted_at: o3Submitted, + confirmed_at: o3Confirmed, + made_at: o3Made, + items: { + create: [ + { + store_id: storeA.id, + product_id: p3.id, + product_name_snapshot: p3.name, + regular_price_snapshot: p3.regular_price, + sale_price_snapshot: p3.sale_price, + quantity: 2, + item_subtotal_price: 44000, + }, + ], + }, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o3Submitted }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o3Confirmed, + }, + { + from_status: 'CONFIRMED', + to_status: 'MADE', + changed_at: o3Made, + }, + ], + }, + }, + }); + + // ── o4: PICKED_UP (리뷰 작성 완료) ── + const o4Sub = new Date(now.getTime() - 14 * day); + const o4Conf = new Date(now.getTime() - 13 * day); + const o4Made = new Date(now.getTime() - 11 * day); + const o4Picked = new Date(now.getTime() - 10 * day); + const o4 = await prisma.order.create({ + data: { + order_number: 'SEED-O4-PICKED-RE', + account_id: user1.id, + status: 'PICKED_UP', + pickup_at: o4Picked, + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 35000, + discount_price: 0, + total_price: 35000, + submitted_at: o4Sub, + confirmed_at: o4Conf, + made_at: o4Made, + picked_up_at: o4Picked, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o4Sub }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o4Conf, + }, + { from_status: 'CONFIRMED', to_status: 'MADE', changed_at: o4Made }, + { from_status: 'MADE', to_status: 'PICKED_UP', changed_at: o4Picked }, + ], + }, + }, + }); + const o4Item = await prisma.orderItem.create({ + data: { + order_id: o4.id, + store_id: storeA.id, + product_id: p1.id, + product_name_snapshot: p1.name, + regular_price_snapshot: p1.regular_price, + sale_price_snapshot: p1.sale_price, + quantity: 1, + item_subtotal_price: 35000, + }, + }); + + // ── o5: PICKED_UP (리뷰 미작성 → hasReviewableItem=true) ── + const o5Picked = new Date(now.getTime() - 5 * day); + const o5 = await prisma.order.create({ + data: { + order_number: 'SEED-O5-PICKED-OK', + account_id: user1.id, + status: 'PICKED_UP', + pickup_at: o5Picked, + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 3000, + discount_price: 0, + total_price: 3000, + submitted_at: new Date(now.getTime() - 8 * day), + confirmed_at: new Date(now.getTime() - 7 * day), + made_at: new Date(now.getTime() - 6 * day), + picked_up_at: o5Picked, + items: { + create: [ + { + store_id: storeB.id, + product_id: p4.id, + product_name_snapshot: p4.name, + regular_price_snapshot: p4.regular_price, + sale_price_snapshot: null, + quantity: 1, + item_subtotal_price: 3000, + }, + ], + }, + status_histories: { + create: [ + { + to_status: 'SUBMITTED', + changed_at: new Date(now.getTime() - 8 * day), + }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: new Date(now.getTime() - 7 * day), + }, + { + from_status: 'CONFIRMED', + to_status: 'MADE', + changed_at: new Date(now.getTime() - 6 * day), + }, + { + from_status: 'MADE', + to_status: 'PICKED_UP', + changed_at: o5Picked, + }, + ], + }, + }, + }); + + // ── o6: CANCELED ── + const o6Sub = new Date(now.getTime() - 6 * day); + const o6Cancel = new Date(now.getTime() - 5 * day); + const o6 = await prisma.order.create({ + data: { + order_number: 'SEED-O6-CANCEL', + account_id: user1.id, + status: 'CANCELED', + pickup_at: new Date(now.getTime() - 3 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 50000, + discount_price: 0, + total_price: 50000, + submitted_at: o6Sub, + canceled_at: o6Cancel, + items: { + create: [ + { + store_id: storeA.id, + product_id: p2.id, + product_name_snapshot: p2.name, + regular_price_snapshot: p2.regular_price, + sale_price_snapshot: null, + quantity: 1, + item_subtotal_price: 50000, + }, + ], + }, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o6Sub }, + { + from_status: 'SUBMITTED', + to_status: 'CANCELED', + changed_at: o6Cancel, + note: '재료 수급 불가', + }, + ], + }, + }, + }); + + return { + o1Submitted: o1.id, + o2Confirmed: o2.id, + o3Made: o3.id, + o4PickedUpReviewed: o4.id, + o4OrderItemId: o4Item.id, + o5PickedUpReviewable: o5.id, + o6Canceled: o6.id, + }; +} diff --git a/prisma/seed/recent-views.ts b/prisma/seed/recent-views.ts new file mode 100644 index 0000000..49ce19f --- /dev/null +++ b/prisma/seed/recent-views.ts @@ -0,0 +1,45 @@ +/** + * 시드 최근 본 상품 (user1, 4건). + * viewed_at에 시간 차이를 두어 정렬 검증 가능. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedRecentViews( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedRecentViews'); + const [p1, p2, p3, p4] = ctx.stores.products; + + const now = Date.now(); + const minute = 60 * 1000; + + await prisma.recentProductView.createMany({ + data: [ + { + account_id: user1.id, + product_id: p1.id, + viewed_at: new Date(now - 5 * minute), + }, + { + account_id: user1.id, + product_id: p2.id, + viewed_at: new Date(now - 30 * minute), + }, + { + account_id: user1.id, + product_id: p3.id, + viewed_at: new Date(now - 2 * 60 * minute), + }, + { + account_id: user1.id, + product_id: p4.id, + viewed_at: new Date(now - 24 * 60 * minute), + }, + ], + }); +} diff --git a/prisma/seed/reviews.ts b/prisma/seed/reviews.ts new file mode 100644 index 0000000..35a1634 --- /dev/null +++ b/prisma/seed/reviews.ts @@ -0,0 +1,48 @@ +/** + * 시드 리뷰. user1이 o4의 OrderItem에 작성한 1건. + * IMAGE 1건 + VIDEO 1건 첨부. + */ +import type { PrismaClient } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import type { SeededOrders } from './orders'; +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedReviews( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores; orders: SeededOrders }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedReviews'); + const [storeA] = ctx.stores.stores; + const [p1] = ctx.stores.products; + const orderItemId = ctx.orders.o4OrderItemId; + + await prisma.review.create({ + data: { + order_item_id: orderItemId, + account_id: user1.id, + store_id: storeA.id, + product_id: p1.id, + rating: new Prisma.Decimal('4.5'), + content: + '레터링이 정말 예쁘게 나왔어요. 케이크 맛도 좋고 다음에 또 주문할게요!', + media: { + create: [ + { + media_type: 'IMAGE', + media_url: 'https://placehold.co/800x800/png?text=Review+Image', + sort_order: 0, + }, + { + media_type: 'VIDEO', + media_url: 'https://placehold.co/800x800/png?text=Review+Video.mp4', + thumbnail_url: 'https://placehold.co/300x300/png?text=Video+Thumb', + sort_order: 1, + }, + ], + }, + }, + }); +} diff --git a/prisma/seed/search-history.ts b/prisma/seed/search-history.ts new file mode 100644 index 0000000..5ac6d2f --- /dev/null +++ b/prisma/seed/search-history.ts @@ -0,0 +1,37 @@ +/** + * 시드 검색 히스토리 (user1, 3건). + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +export async function seedSearchHistory( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedSearchHistory'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + + await prisma.searchHistory.createMany({ + data: [ + { + account_id: user1.id, + keyword: '레터링 케이크', + last_used_at: new Date(now - 1 * hour), + }, + { + account_id: user1.id, + keyword: '도넛', + last_used_at: new Date(now - 5 * hour), + }, + { + account_id: user1.id, + keyword: '캐릭터', + last_used_at: new Date(now - 24 * hour), + }, + ], + }); +} diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts new file mode 100644 index 0000000..175a9c7 --- /dev/null +++ b/prisma/seed/stores.ts @@ -0,0 +1,187 @@ +/** + * 시드 매장 + 상품. + * + * 매장 2개 (각각 SELLER 계정 1개씩 생성), 상품 5개: + * p1 레터링 케이크 (sale_price 있음) + * p2 캐릭터 케이크 (sale_price 없음, 옵션 그룹 포함) + * p3 미니 케이크 세트 + * p4 글레이즈드 도넛 (다른 매장) + * p5 비활성 상품 (찜 가시성 검증용) + */ +import type { PrismaClient, Product, Store } from '@prisma/client'; + +import { SEED_STORE_NAME_PREFIX } from './idempotent'; + +export interface SeededStores { + stores: Store[]; + products: Product[]; + optionGroupIds: { p2GroupId: bigint; p2OptionItemId: bigint }; +} + +export async function seedStores(prisma: PrismaClient): Promise { + // 매장 1: 케이크샵 A (소속 seller 계정 자동 생성) + const sellerA = await prisma.account.create({ + data: { + account_type: 'SELLER', + status: 'ACTIVE', + email: 'seed-seller-a@dev.caquick', + name: '케이크샵 A 운영자', + }, + }); + const storeA = await prisma.store.create({ + data: { + seller_account_id: sellerA.id, + store_name: `${SEED_STORE_NAME_PREFIX}케이크샵 A`, + store_phone: '02-1111-2222', + address_full: '서울특별시 강남구 테헤란로 1길 10', + address_city: '서울특별시', + address_district: '강남구', + address_neighborhood: '역삼동', + latitude: 37.5012 as unknown as never, + longitude: 127.0396 as unknown as never, + business_hours_text: '매일 09:00 ~ 18:00 (화요일 정기 휴무)', + is_active: true, + }, + }); + + // 매장 2: 도넛샵 B + const sellerB = await prisma.account.create({ + data: { + account_type: 'SELLER', + status: 'ACTIVE', + email: 'seed-seller-b@dev.caquick', + name: '도넛샵 B 운영자', + }, + }); + const storeB = await prisma.store.create({ + data: { + seller_account_id: sellerB.id, + store_name: `${SEED_STORE_NAME_PREFIX}도넛샵 B`, + store_phone: '02-3333-4444', + address_full: '서울특별시 마포구 와우산로 5', + address_city: '서울특별시', + address_district: '마포구', + address_neighborhood: '서교동', + business_hours_text: '평일 11:00 ~ 21:00', + is_active: true, + }, + }); + + // 상품 + const p1 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 레터링 케이크', + description: '원하는 글씨를 손글씨로 새겨주는 레터링 케이크입니다.', + regular_price: 40000, + sale_price: 35000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Lettering+Cake', + sort_order: 0, + }, + ], + }, + }, + }); + + const p2 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 캐릭터 케이크', + description: '원하시는 캐릭터를 케이크 위에 그려드립니다.', + regular_price: 50000, + sale_price: null, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Character+Cake', + sort_order: 0, + }, + ], + }, + }, + }); + + // p2에 옵션 그룹 1건 + 옵션 아이템 2건 (주문 상세 selectedOptions 검증) + const p2OptionGroup = await prisma.productOptionGroup.create({ + data: { + product_id: p2.id, + name: '케이크 사이즈', + is_required: true, + min_select: 1, + max_select: 1, + sort_order: 0, + is_active: true, + option_items: { + create: [ + { title: '미니 (4호)', price_delta: 0, sort_order: 0 }, + { title: '레귤러 (6호)', price_delta: 10000, sort_order: 1 }, + ], + }, + }, + include: { option_items: true }, + }); + const p2RegularItem = p2OptionGroup.option_items.find( + (i) => i.title === '레귤러 (6호)', + )!; + + const p3 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 미니 케이크 세트', + description: '한 입 크기의 미니 케이크 6종 세트.', + regular_price: 25000, + sale_price: 22000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Mini+Cake+Set', + sort_order: 0, + }, + ], + }, + }, + }); + + const p4 = await prisma.product.create({ + data: { + store_id: storeB.id, + name: '[SEED] 글레이즈드 도넛', + description: '갓 만든 글레이즈드 도넛 1개.', + regular_price: 3000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Glazed+Donut', + sort_order: 0, + }, + ], + }, + }, + }); + + const p5 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] (비활성) 단종 상품', + description: '판매 중단된 상품. 찜 가시성 정책 검증용.', + regular_price: 10000, + is_active: false, + }, + }); + + return { + stores: [storeA, storeB], + products: [p1, p2, p3, p4, p5], + optionGroupIds: { + p2GroupId: p2OptionGroup.id, + p2OptionItemId: p2RegularItem.id, + }, + }; +} diff --git a/prisma/seed/users.ts b/prisma/seed/users.ts new file mode 100644 index 0000000..fb4f834 --- /dev/null +++ b/prisma/seed/users.ts @@ -0,0 +1,58 @@ +/** + * 시드 유저 + 프로필. + * + * - user1: 온보딩 완료 — 마이페이지 API 전반 검증의 메인 계정 + * - user2: 온보딩 미완료 — me.needsProfile=true 검증용 + */ +import type { Account, PrismaClient } from '@prisma/client'; + +import { SEED_USER_EMAIL_PREFIX } from './idempotent'; + +export interface SeededUser extends Account { + profileId: bigint; +} + +export async function seedUsers(prisma: PrismaClient): Promise { + const now = new Date(); + + const user1 = await prisma.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: `${SEED_USER_EMAIL_PREFIX}1@dev.caquick`, + name: '테스트 유저 1', + }, + }); + const profile1 = await prisma.userProfile.create({ + data: { + account_id: user1.id, + nickname: 'seedTester1', + birth_date: new Date(Date.UTC(1995, 4, 15)), + phone_number: '010-1111-2222', + onboarding_completed_at: now, + }, + }); + + const user2 = await prisma.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: `${SEED_USER_EMAIL_PREFIX}2@dev.caquick`, + name: null, // 이름 없음 → 온보딩 미완료 + }, + }); + // 미완료 사용자도 UserProfile은 1:1 필수일 수 있음 — schema에 unique account_id라 row는 있어야 함 + const profile2 = await prisma.userProfile.create({ + data: { + account_id: user2.id, + // nickname은 unique 필수 → 임시 nickname 부여 + nickname: 'seedTester2-tmp', + onboarding_completed_at: null, + }, + }); + + return [ + { ...user1, profileId: profile1.id }, + { ...user2, profileId: profile2.id }, + ]; +} diff --git a/prisma/seed/wishlist.ts b/prisma/seed/wishlist.ts new file mode 100644 index 0000000..ca6a9b5 --- /dev/null +++ b/prisma/seed/wishlist.ts @@ -0,0 +1,30 @@ +/** + * 시드 찜 목록 (user1). + * + * - p1 (활성, recent-view에도 있음) → recent-view에서 isWishlisted=true + * - p3 (활성, recent-view에 없음) → myWishlist에는 보이지만 recent-view에는 안 나옴 + * - p5 (비활성 상품) → visibleWishlistWhere로 myWishlist/wishlistCount에서 제외 + * + * 결과: wishlistCount=2, myWishlist.totalCount=2 + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedWishlist( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedWishlist'); + const [p1, , p3, , p5] = ctx.stores.products; + + await prisma.wishlistItem.createMany({ + data: [ + { account_id: user1.id, product_id: p1.id }, + { account_id: user1.id, product_id: p3.id }, + { account_id: user1.id, product_id: p5.id }, + ], + }); +} From 1e1d317664741498f84c59a3faea55e59a409e28 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 6 May 2026 04:24:49 +0900 Subject: [PATCH 2/3] =?UTF-8?q?revert:=20README=20seed=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EA=B1=B0=20(=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9C=BC=EB=A1=9C=20=EB=AF=B8=EB=A3=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index 9f909c7..da24eaa 100644 --- a/README.md +++ b/README.md @@ -57,23 +57,6 @@ $ yarn run test:e2e $ yarn run test:cov ``` -## 테스트 데이터 시드 (로컬 한정) - -마이페이지 API를 시각적으로 검증하기 위한 테스트 데이터를 한 번에 채울 수 있습니다. - -```bash -# 사전: docker compose up -d 로 MySQL 컨테이너가 떠 있어야 함 -# 사전: yarn prisma:migrate:deploy (또는 :dev) 로 마이그레이션이 적용되어 있어야 함 - -$ yarn prisma:seed -``` - -- 실행 시 기존 시드 영역(`seed-user-*` 이메일, `[SEED] *` 매장명)을 정리한 뒤 재삽입합니다 (idempotent). -- 발급된 테스트 `accountId`가 콘솔에 출력됩니다 — Dev 토큰 발급 헬퍼와 함께 사용하세요. -- `NODE_ENV=production` 에서는 자동으로 차단됩니다. - -자세한 시드 시나리오와 검증 가능한 API 매핑은 노션의 `마이페이지 API 가이드` 를 참고하세요. - ## Deployment When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. From fe6b876070512eaeef86dc7d59e8acf014582d7e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 6 May 2026 04:33:42 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(auth):=20dev=20=EC=A0=84=EC=9A=A9=20ac?= =?UTF-8?q?cess=20token=20=EB=B0=9C=EA=B8=89=20endpoint=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 개발자가 시드(yarn prisma:seed) accountId로 OIDC 흐름을 거치지 않고 GraphQL Playground에서 곧장 마이페이지 API를 시험할 수 있도록 dev 전용 헬퍼 추가. ## 변경 사항 - AuthService.issueDevAccessToken(accountId) - findAccountForJwt로 활성 USER 검증 (NotFound / Forbidden 분기) - 기존 signAccessToken을 재사용하여 access token 생성 - AuthController.devIssueToken (POST /auth/dev/issue-token) - NODE_ENV=production이면 ForbiddenException으로 즉시 차단 - body.accountId 누락/형식 오류 시 BadRequestException - 응답: { accessToken, tokenType: 'Bearer', expiresInSeconds } ## 회귀 테스트 - auth.service.spec.ts: 정상 발급 / NotFound / Forbidden 3건 - auth.controller.spec.ts: production 차단 / 누락 / 형식오류 / 정상 4건 전체 875 tests 통과 (+7). --- src/features/auth/auth.service.spec.ts | 58 +++++++++++++++++- src/features/auth/auth.service.ts | 32 ++++++++++ .../auth/controllers/auth.controller.spec.ts | 60 +++++++++++++++++- .../auth/controllers/auth.controller.ts | 61 +++++++++++++++++++ 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/src/features/auth/auth.service.spec.ts b/src/features/auth/auth.service.spec.ts index ddb5ad3..ec106cc 100644 --- a/src/features/auth/auth.service.spec.ts +++ b/src/features/auth/auth.service.spec.ts @@ -1,4 +1,8 @@ -import { UnauthorizedException } from '@nestjs/common'; +import { + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; @@ -38,6 +42,7 @@ describe('AuthService', () => { rotateRefreshSession: jest.fn(), revokeRefreshSession: jest.fn(), findAccountForMe: jest.fn(), + findAccountForJwt: jest.fn(), createRefreshSession: jest.fn(), } as unknown as jest.Mocked; @@ -509,4 +514,55 @@ describe('AuthService', () => { expect(returnToCookie![1]).toBe('http://localhost:3000'); }); }); + + describe('issueDevAccessToken', () => { + beforeEach(() => { + mockConfig.get.mockImplementation((key: string) => { + if (key === 'JWT_ACCESS_EXPIRES_SECONDS') return '900'; + return undefined; + }); + mockJwt.sign.mockReturnValue('signed-access-token'); + }); + + it('정상 발급: 활성 USER 계정이면 access token + 만료 정보를 반환한다', async () => { + mockRepo.findAccountForJwt.mockResolvedValue({ + id: BigInt(1), + status: 'ACTIVE', + account_type: 'USER', + }); + + const result = await service.issueDevAccessToken(BigInt(1)); + + expect(result).toEqual({ + accessToken: 'signed-access-token', + tokenType: 'Bearer', + expiresInSeconds: 900, + }); + expect(mockJwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ sub: '1', typ: 'access' }), + ); + }); + + it('존재하지 않는 accountId면 NotFoundException', async () => { + mockRepo.findAccountForJwt.mockResolvedValue(null); + + await expect(service.issueDevAccessToken(BigInt(999))).rejects.toThrow( + NotFoundException, + ); + expect(mockJwt.sign).not.toHaveBeenCalled(); + }); + + it('비활성(SUSPENDED) 계정이면 ForbiddenException', async () => { + mockRepo.findAccountForJwt.mockResolvedValue({ + id: BigInt(2), + status: 'SUSPENDED', + account_type: 'USER', + }); + + await expect(service.issueDevAccessToken(BigInt(2))).rejects.toThrow( + ForbiddenException, + ); + expect(mockJwt.sign).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index de8d92d..3df8d64 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -4,6 +4,7 @@ import { BadRequestException, ForbiddenException, Injectable, + NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -204,6 +205,37 @@ export class AuthService { return { accessToken }; } + /** + * 개발 환경 한정: accountId 만으로 access token을 즉시 발급한다. + * + * 운영자/FE가 OIDC 흐름을 거치지 않고 시드 데이터의 accountId 로 곧장 + * GraphQL API를 시험하기 위함. production 환경에서는 controller 입구에서 + * 차단된다. + * + * @param accountId 발급 대상 account id + * @returns 발급된 access token + 만료(초) + */ + async issueDevAccessToken(accountId: bigint): Promise<{ + accessToken: string; + tokenType: 'Bearer'; + expiresInSeconds: number; + }> { + const account = await this.repo.findAccountForJwt(accountId); + if (!account) { + throw new NotFoundException('Account not found.'); + } + if (account.status !== 'ACTIVE') { + throw new ForbiddenException('Account is not active.'); + } + + const accessToken = this.signAccessToken(accountId); + return { + accessToken, + tokenType: 'Bearer', + expiresInSeconds: this.getAccessExpiresSeconds(), + }; + } + /** * 판매자 refresh 재발급 * diff --git a/src/features/auth/controllers/auth.controller.spec.ts b/src/features/auth/controllers/auth.controller.spec.ts index 0722646..65bf12a 100644 --- a/src/features/auth/controllers/auth.controller.spec.ts +++ b/src/features/auth/controllers/auth.controller.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import type { Request, Response } from 'express'; @@ -29,6 +29,7 @@ describe('AuthController', () => { refreshSeller: jest.fn(), logoutSeller: jest.fn(), changeSellerPassword: jest.fn(), + issueDevAccessToken: jest.fn(), } as unknown as jest.Mocked; const module: TestingModule = await Test.createTestingModule({ @@ -208,4 +209,61 @@ describe('AuthController', () => { ), ).rejects.toThrow(BadRequestException); }); + + describe('devIssueToken', () => { + const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + }); + + it('NODE_ENV=production이면 ForbiddenException', async () => { + process.env.NODE_ENV = 'production'; + const res = mockRes(); + + await expect( + controller.devIssueToken({ accountId: '1' }, res), + ).rejects.toThrow(ForbiddenException); + expect(auth.issueDevAccessToken).not.toHaveBeenCalled(); + }); + + it('accountId 문자열이 누락되면 BadRequestException', async () => { + process.env.NODE_ENV = 'development'; + const res = mockRes(); + + await expect( + controller.devIssueToken({} as unknown as { accountId: string }, res), + ).rejects.toThrow(BadRequestException); + }); + + it('accountId가 BigInt로 파싱 불가하면 BadRequestException', async () => { + process.env.NODE_ENV = 'development'; + const res = mockRes(); + + await expect( + controller.devIssueToken({ accountId: 'not-a-number' }, res), + ).rejects.toThrow(BadRequestException); + }); + + it('정상 발급: service 위임 + 200 응답', async () => { + process.env.NODE_ENV = 'development'; + const res = mockRes(); + + auth.issueDevAccessToken.mockResolvedValue({ + accessToken: 't', + tokenType: 'Bearer', + expiresInSeconds: 900, + }); + + await controller.devIssueToken({ accountId: '5' }, res); + + expect(auth.issueDevAccessToken).toHaveBeenCalledWith(BigInt(5)); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + accessToken: 't', + tokenType: 'Bearer', + expiresInSeconds: 900, + }); + }); + }); }); diff --git a/src/features/auth/controllers/auth.controller.ts b/src/features/auth/controllers/auth.controller.ts index 0f5cd7e..575e288 100644 --- a/src/features/auth/controllers/auth.controller.ts +++ b/src/features/auth/controllers/auth.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, Controller, + ForbiddenException, Get, Param, Post, @@ -266,6 +267,54 @@ export class AuthController { res.status(204).send(); } + /** + * Dev 전용 access token 발급 (개발 환경 한정) + * + * POST /auth/dev/issue-token + * + * - NODE_ENV=production 인 경우 ForbiddenException + * - body: { accountId: string } + * - 응답: { accessToken, tokenType, expiresInSeconds } + * + * 시드(yarn prisma:seed) 데이터의 accountId 로 곧장 GraphQL Playground에서 + * 마이페이지 API를 시험해 볼 수 있도록 OIDC 흐름을 우회한다. + */ + @ApiOperation({ + summary: '[DEV ONLY] Access token 즉시 발급', + description: + '개발 환경에서 OIDC 흐름 없이 accountId로 access token을 발급한다. NODE_ENV=production 에서는 차단된다.', + }) + @ApiOkResponse({ + description: 'Dev access token', + schema: { + type: 'object', + properties: { + accessToken: { type: 'string' }, + tokenType: { type: 'string', example: 'Bearer' }, + expiresInSeconds: { type: 'number', example: 900 }, + }, + required: ['accessToken', 'tokenType', 'expiresInSeconds'], + }, + }) + @Post('dev/issue-token') + async devIssueToken( + @Body() body: DevIssueTokenBody, + @Res() res: Response, + ): Promise { + if (process.env.NODE_ENV === 'production') { + throw new ForbiddenException( + '/auth/dev/issue-token은 개발 환경에서만 사용 가능합니다.', + ); + } + if (!body || typeof body.accountId !== 'string') { + throw new BadRequestException('accountId(string)가 필요합니다.'); + } + + const accountId = parseAccountIdString(body.accountId); + const result = await this.auth.issueDevAccessToken(accountId); + res.status(200).json(result); + } + /** * 판매자 비밀번호 변경 * @@ -313,6 +362,10 @@ interface SellerChangePasswordBody { newPassword: string; } +interface DevIssueTokenBody { + accountId: string; +} + function parseAccountId(user: JwtUser): bigint { try { return BigInt(user.accountId); @@ -320,3 +373,11 @@ function parseAccountId(user: JwtUser): bigint { throw new BadRequestException('Invalid account id.'); } } + +function parseAccountIdString(raw: string): bigint { + try { + return BigInt(raw); + } catch { + throw new BadRequestException('Invalid account id.'); + } +}