diff --git a/scripts/seed-reward-products.ts b/scripts/seed-reward-products.ts index 2cb2b75..8c48a81 100644 --- a/scripts/seed-reward-products.ts +++ b/scripts/seed-reward-products.ts @@ -25,9 +25,6 @@ async function main() { name: p.name, pointPrice: p.pointPrice, displayOrder: p.displayOrder, - totalStock: existing?.totalStock ?? 0, - reservedStock: existing?.reservedStock ?? 0, - safetyStock: existing?.safetyStock ?? 0, ...draft, createdAt: existing?.createdAt ?? now, updatedAt: now, diff --git a/scripts/upload-reward-product-assets.ts b/scripts/upload-reward-product-assets.ts index c4882b5..e8903f4 100644 --- a/scripts/upload-reward-product-assets.ts +++ b/scripts/upload-reward-product-assets.ts @@ -146,9 +146,6 @@ async function upsertProduct( displayOrder: { integerValue: String(spec.displayOrder) }, mainImages: stringArray(mainImages), detailImages: stringArray(detailImages), - totalStock: existingProduct?.fields?.totalStock ?? { integerValue: "0" }, - reservedStock: existingProduct?.fields?.reservedStock ?? { integerValue: "0" }, - safetyStock: existingProduct?.fields?.safetyStock ?? { integerValue: "0" }, createdAt: existingProduct?.fields?.createdAt ?? { timestampValue: now }, updatedAt: { timestampValue: now }, }); diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index e2e4494..ca0186b 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -7,7 +7,6 @@ import { getWallet } from "../repositories/walletRepository"; import { listLedger } from "../repositories/pointLedgerRepository"; import { firestore } from "../firebase"; import { Timestamp } from "firebase-admin/firestore"; -import { availableStock, productRef } from "../repositories/productRepository"; import type { OrderStatus, ProductDoc } from "../types/reward"; import { transitionOrder } from "../services/orderService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService"; @@ -25,12 +24,21 @@ export const admin = onRequest(async (req, res) => { if (tail === "product/upsert" && req.method === "POST") { const { id, name, pointPrice, active, redeemable, displayOrder, - mainImages, detailImages, + mainImages, detailImages, optionLabel, options, } = req.body ?? {}; const validUrls = (value: unknown, max: number) => Array.isArray(value) && value.length <= max && value.every((url) => typeof url === "string" && url.length <= 2048 && /^https:\/\//.test(url) ); + // 옵션은 재고 없는 선택지 라벨(예: 색상)이다. 요청에서 빠지면 옵션 없는 상품으로 저장한다. + const validOptions = + options === undefined || + (Array.isArray(options) && options.length >= 1 && options.length <= 20 && + options.every((o) => + typeof o?.id === "string" && /^[A-Za-z0-9_-]{1,100}$/.test(o.id) && + typeof o?.name === "string" && o.name.length >= 1 && o.name.length <= 100 + ) && + new Set(options.map((o) => o.id)).size === options.length); const valid = /^[A-Za-z0-9_-]{1,100}$/.test(id) && typeof name === "string" && name.length >= 1 && name.length <= 100 && @@ -38,7 +46,10 @@ export const admin = onRequest(async (req, res) => { typeof active === "boolean" && typeof redeemable === "boolean" && Number.isSafeInteger(displayOrder) && displayOrder >= 0 && validUrls(mainImages, 10) && validUrls(detailImages, 30) && - (!active || !redeemable || mainImages.length > 0); + (!active || !redeemable || mainImages.length > 0) && + validOptions && + (optionLabel === undefined || + (typeof optionLabel === "string" && optionLabel.length >= 1 && optionLabel.length <= 50)); if (!valid) throw new HttpError(400, "invalid product", "INVALID_INPUT"); const now = Timestamp.now(); await firestore.runTransaction(async (tx) => { @@ -48,9 +59,12 @@ export const admin = onRequest(async (req, res) => { tx.set(productRef, { name, pointPrice, active, redeemable, displayOrder, mainImages, detailImages, - totalStock: previous?.totalStock ?? 0, - reservedStock: previous?.reservedStock ?? 0, - safetyStock: previous?.safetyStock ?? 0, + ...(options !== undefined + ? { + optionLabel: optionLabel ?? "옵션", + options: options.map((o: { id: string; name: string }) => ({ id: o.id, name: o.name })), + } + : {}), createdAt: previous?.createdAt ?? now, updatedAt: now, }); @@ -59,8 +73,7 @@ export const admin = onRequest(async (req, res) => { res.json({ id }); return; } - if (tail === "stock/adjust" && req.method === "POST") { const { productId, deltaTotal, safetyStock } = req.body ?? {}; if (!/^[A-Za-z0-9_-]{1,100}$/.test(productId) || !Number.isSafeInteger(deltaTotal ?? 0) || (safetyStock !== undefined && (!Number.isSafeInteger(safetyStock) || safetyStock < 0))) throw new HttpError(400, "invalid stock adjustment", "INVALID_INPUT"); const result = await firestore.runTransaction(async (tx) => { const ref = productRef(productId); const snap = await tx.get(ref); if (!snap.exists) throw new HttpError(404, "product not found", "PRODUCT_NOT_FOUND"); const product = snap.data() as ProductDoc; const next = { totalStock: product.totalStock + (deltaTotal ?? 0), reservedStock: product.reservedStock, safetyStock: safetyStock ?? product.safetyStock, updatedAt: Timestamp.now() }; if (next.totalStock < 0 || availableStock(next) < 0) throw new HttpError(409, "invalid stock adjustment", "INVALID_STOCK"); tx.update(ref, next); return { ...next, availableStock: availableStock(next) }; }); res.json(result); return; } - if (tail === "order/status" && req.method === "POST") { const { orderId, status, restock } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true, restock })); return; } + if (tail === "order/status" && req.method === "POST") { const { orderId, status } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true })); return; } if (tail === "game/end" && req.method === "POST") { const { gameId, winningTeamCode } = req.body ?? {}; diff --git a/src/repositories/productRepository.ts b/src/repositories/productRepository.ts index 2ea07a3..a5d7be2 100644 --- a/src/repositories/productRepository.ts +++ b/src/repositories/productRepository.ts @@ -1,6 +1,5 @@ import { firestore } from "../firebase"; import type { ProductDoc } from "../types/reward"; export function productRef(id: string) { return firestore.doc(`products/${id}`); } -export function availableStock(product: Pick) { return product.totalStock - product.reservedStock - product.safetyStock; } export async function listProducts(): Promise> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); } export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; } diff --git a/src/services/orderService.ts b/src/services/orderService.ts index c63df2a..8f30a19 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -2,39 +2,56 @@ import { Timestamp, type DocumentSnapshot } from "firebase-admin/firestore"; import { firestore } from "../firebase"; import { HttpError } from "../middleware/errors"; import { getOrder, listOrders, orderRef } from "../repositories/orderRepository"; -import { availableStock, productRef } from "../repositories/productRepository"; +import { productRef } from "../repositories/productRepository"; import { applyPointChangesTx } from "./pointService"; import { getWalletTx } from "../repositories/walletRepository"; import { PointLedgerType } from "../types/points"; -import { ORDER_TRANSITIONS, type OrderDoc, type OrderStatus, type ProductDoc, type Recipient } from "../types/reward"; +import { ORDER_TRANSITIONS, type OrderDoc, type OrderItem, type OrderStatus, type ProductDoc, type ProductOption, type Recipient } from "../types/reward"; const KEY = /^[A-Za-z0-9_-]{1,100}$/; -export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number }>; recipient: Recipient } -function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100)) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); } +export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number; optionId?: string }>; recipient: Recipient } +function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100 || (x.optionId !== undefined && !KEY.test(x.optionId)))) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); } + +/** 옵션 상품이면 주문 항목의 optionId 를 실제 옵션으로 해석하고, 무옵션 상품이면 optionId 를 거부한다. */ +export function resolveOrderOption(product: Pick, optionId: string | undefined): ProductOption | null { + const options = product.options ?? []; + if (options.length === 0) { + if (optionId !== undefined) throw new HttpError(400, "product has no options", "INVALID_INPUT"); + return null; + } + const option = options.find((o) => o.id === optionId); + if (!option) throw new HttpError(400, "option required", "OPTION_REQUIRED"); + return option; +} export async function createOrder(uid: string, input: CreateOrderInput) { validateInput(input); const id = `${uid}_${input.clientIdempotencyKey}`; const now = Timestamp.now(); return firestore.runTransaction(async (tx) => { const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true }; - const products: DocumentSnapshot[] = []; for (const item of input.items) products.push(await tx.get(productRef(item.productId))); + // 같은 상품의 서로 다른 옵션이 별개 항목으로 들어올 수 있으므로 상품 문서는 productId 당 한 번만 읽는다. + // 재고는 별도 플랫폼에서 관리하므로 여기서는 검사·예약하지 않는다. + const snapById = new Map(); for (const item of input.items) { if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); } const currentWallet = await getWalletTx(tx, uid); - const orderItems = input.items.map((item, i) => { const p = products[i].data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); if (availableStock(p) < item.qty) throw new HttpError(409, "out of stock", "OUT_OF_STOCK"); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name }; }); + const orderItems: OrderItem[] = input.items.map((item) => { + const p = snapById.get(item.productId)!.data() as ProductDoc | undefined; + if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); + const option = resolveOrderOption(p, item.optionId); + return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) }; + }); const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]); - for (let i = 0; i < input.items.length; i++) tx.update(productRef(input.items[i].productId), { reservedStock: (products[i].data() as ProductDoc).reservedStock + input.items[i].qty, updatedAt: now }); const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now }; tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance }; }); } -export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; restock?: boolean; system?: boolean } = {}) { +export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; system?: boolean } = {}) { return firestore.runTransaction(async (tx) => { const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc; if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN"); - const productSnaps: DocumentSnapshot[] = []; for (const item of order.items) productSnaps.push(await tx.get(productRef(item.productId))); const now = Timestamp.now(); + const now = Timestamp.now(); let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; } if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]); - order.items.forEach((item, i) => { const product = productSnaps[i].data() as ProductDoc; const patch: Partial = { updatedAt: now }; if (target === "cancelled") patch.reservedStock = product.reservedStock - item.qty; if (target === "confirmed") { patch.reservedStock = product.reservedStock - item.qty; patch.totalStock = product.totalStock - item.qty; } if (target === "refunded" && opts.restock !== false) patch.totalStock = product.totalStock + item.qty; tx.update(productRef(item.productId), patch); }); const patch: Record = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch); return { id: orderId, status: target }; }); diff --git a/src/services/rewardCatalogService.ts b/src/services/rewardCatalogService.ts index 743e529..e1c3363 100644 --- a/src/services/rewardCatalogService.ts +++ b/src/services/rewardCatalogService.ts @@ -41,6 +41,14 @@ export async function getCatalogProduct(id: string): Promise ({ id: o.id, name: o.name })), + } + : {}), }; }); } diff --git a/src/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts index 85c05dd..23d025e 100644 --- a/src/types/dto/rewardDto.ts +++ b/src/types/dto/rewardDto.ts @@ -51,6 +51,8 @@ export interface OrderItemDto { qty: number; pointPrice: number; name: string; + optionId?: string; + optionName?: string; } export interface RecipientDto { @@ -113,8 +115,18 @@ export interface ProductSummaryDto { mainImages: string[]; } +/** 상품 옵션(예: 색상). 재고는 별도 플랫폼 관리라 선택지 라벨만 내려간다. */ +export interface ProductOptionDto { + id: string; + name: string; +} + export interface ProductDetailDto extends ProductSummaryDto { detailImages: string[]; + /** 옵션 선택 UI 라벨(예: "색상"). options 가 있을 때만 내려간다. */ + optionLabel?: string; + /** 비어 있지 않으면 주문 시 옵션 선택이 필수다. */ + options?: ProductOptionDto[]; } export interface EligibilityDto { @@ -217,6 +229,8 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto { qty: item.qty, pointPrice: item.pointPrice, name: item.name, + optionId: item.optionId, + optionName: item.optionName, })), totalPoints: doc.totalPoints, recipient: { diff --git a/src/types/reward.ts b/src/types/reward.ts index 8c1376e..1e9bf61 100644 --- a/src/types/reward.ts +++ b/src/types/reward.ts @@ -1,5 +1,14 @@ import type { Timestamp } from "firebase-admin/firestore"; +/** + * 상품 옵션(예: 색상). 재고는 별도 플랫폼에서 관리하므로 옵션은 선택지 + * 라벨일 뿐 재고를 갖지 않는다 — 주문에 어떤 옵션을 골랐는지 기록하는 용도다. + */ +export interface ProductOption { + id: string; + name: string; +} + export interface ProductDoc { name: string; pointPrice: number; @@ -10,12 +19,10 @@ export interface ProductDoc { mainImages: string[]; /** 상세 페이지에서 배열 순서대로 위에서 아래로 노출한다. */ detailImages: string[]; - /** 실재고. 예약 확정 시 차감한다. */ - totalStock: number; - /** 주문에 예약되어 아직 확정되지 않은 재고. */ - reservedStock: number; - /** 판매하지 않고 남겨 둘 안전재고. */ - safetyStock: number; + /** 옵션 선택 UI 의 라벨(예: "색상"). options 가 있을 때만 의미가 있다. */ + optionLabel?: string; + /** 배열 순서 = 노출 순서. 비어 있지 않으면 주문 시 optionId 가 필수다. */ + options?: ProductOption[]; createdAt: Timestamp; updatedAt: Timestamp; } @@ -25,7 +32,11 @@ export const ORDER_TRANSITIONS: Record = { preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"], delivered: ["refunded"], cancelled: [], refunded: [], }; -export interface OrderItem { productId: string; qty: number; pointPrice: number; name: string } +export interface OrderItem { + productId: string; qty: number; pointPrice: number; name: string; + /** 옵션 상품이면 주문 시점의 옵션 스냅샷. */ + optionId?: string; optionName?: string; +} export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string } export interface OrderDoc { uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus; diff --git a/tests/services/orderService.test.ts b/tests/services/orderService.test.ts new file mode 100644 index 0000000..e697b07 --- /dev/null +++ b/tests/services/orderService.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveOrderOption } from "../../src/services/orderService"; +import { HttpError } from "../../src/middleware/errors"; + +const options = [ + { id: "blue", name: "블루" }, + { id: "red", name: "레드" }, +]; + +describe("resolveOrderOption", () => { + it("옵션 상품은 optionId 를 실제 옵션으로 해석한다", () => { + expect(resolveOrderOption({ options }, "blue")).toEqual({ id: "blue", name: "블루" }); + }); + + it("옵션 상품에 optionId 가 없으면 OPTION_REQUIRED 로 거부한다", () => { + expect(() => resolveOrderOption({ options }, undefined)).toThrowError( + expect.objectContaining({ status: 400, code: "OPTION_REQUIRED" }) as HttpError, + ); + }); + + it("옵션 상품에 존재하지 않는 optionId 도 OPTION_REQUIRED 로 거부한다", () => { + expect(() => resolveOrderOption({ options }, "green")).toThrowError( + expect.objectContaining({ status: 400, code: "OPTION_REQUIRED" }) as HttpError, + ); + }); + + it("무옵션 상품은 optionId 없이 null 을 돌려준다", () => { + expect(resolveOrderOption({}, undefined)).toBeNull(); + expect(resolveOrderOption({ options: [] }, undefined)).toBeNull(); + }); + + it("무옵션 상품에 optionId 가 오면 INVALID_INPUT 으로 거부한다", () => { + expect(() => resolveOrderOption({}, "blue")).toThrowError( + expect.objectContaining({ status: 400, code: "INVALID_INPUT" }) as HttpError, + ); + }); +}); diff --git a/tests/services/rewardCatalogService.test.ts b/tests/services/rewardCatalogService.test.ts index 50aaf32..cc1f4cd 100644 --- a/tests/services/rewardCatalogService.test.ts +++ b/tests/services/rewardCatalogService.test.ts @@ -30,9 +30,6 @@ describe("rewardCatalogService image contract", () => { displayOrder: 999, mainImages, detailImages, - totalStock: 10, - reservedStock: 2, - safetyStock: 1, createdAt: now, updatedAt: now, }); @@ -45,6 +42,7 @@ describe("rewardCatalogService image contract", () => { expect(product).not.toHaveProperty("totalStock"); expect(product).not.toHaveProperty("reservedStock"); expect(product).not.toHaveProperty("safetyStock"); + expect(product).not.toHaveProperty("options"); }); it("상세에는 mainImages와 detailImages를 모두 순서대로 노출한다", async () => { @@ -54,4 +52,53 @@ describe("rewardCatalogService image contract", () => { detailImages, }); }); + + it("옵션이 없는 상품은 상세에 optionLabel/options 키가 없다", async () => { + const product = await getCatalogProduct(id); + expect(product).not.toHaveProperty("optionLabel"); + expect(product).not.toHaveProperty("options"); + }); +}); + +describe("rewardCatalogService option contract", () => { + const optionId = "catalog-options-test"; + + beforeEach(async () => { + invalidateRewardCatalog(optionId); + await firestore.doc(`products/${optionId}`).delete(); + const now = Timestamp.now(); + await firestore.doc(`products/${optionId}`).set({ + name: "옵션 계약 상품", + pointPrice: 2600, + active: true, + redeemable: true, + displayOrder: 998, + mainImages, + detailImages, + optionLabel: "색상", + options: [ + { id: "blue", name: "블루" }, + { id: "red", name: "레드" }, + ], + createdAt: now, + updatedAt: now, + }); + }); + + it("상세에는 옵션 라벨과 선택지를 문서 순서대로 노출한다", async () => { + expect(await getCatalogProduct(optionId)).toMatchObject({ + id: optionId, + optionLabel: "색상", + options: [ + { id: "blue", name: "블루" }, + { id: "red", name: "레드" }, + ], + }); + }); + + it("목록에는 옵션을 노출하지 않는다", async () => { + const product = (await getCatalog()).find((item) => item.id === optionId); + expect(product).not.toHaveProperty("optionLabel"); + expect(product).not.toHaveProperty("options"); + }); }); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts index c064176..721e193 100644 --- a/tests/types/rewardDto.test.ts +++ b/tests/types/rewardDto.test.ts @@ -196,4 +196,21 @@ describe("toOrderDto", () => { const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc; expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly"); }); + + it("옵션 스냅샷이 있으면 항목에 optionId/optionName 을 그대로 내보낸다", () => { + const withOption: OrderDoc = { + ...baseOrder, + items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" }], + }; + const json = wire(toOrderDto("o1", withOption)); + expect(json.items).toEqual([ + { productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" }, + ]); + }); + + it("옵션이 없는 항목은 응답에서 optionId/optionName 키가 사라진다", () => { + const json = wire(toOrderDto("o1", baseOrder)) as { items: Array> }; + expect(json.items[0]).not.toHaveProperty("optionId"); + expect(json.items[0]).not.toHaveProperty("optionName"); + }); });