Add product options and remove server-side stock management
- 상품에 재고 없는 옵션(optionLabel/options — 예: 키링 색상)을 도입하고 주문 항목에 optionId/optionName 스냅샷 저장 - 주문 생성 시 옵션 검증: 옵션 상품에 미선택이면 OPTION_REQUIRED, 무옵션 상품에 optionId가 오면 INVALID_INPUT - 같은 상품의 색상별 항목을 지원하도록 상품 문서를 productId당 한 번만 읽게 정리 - 재고는 별도 플랫폼에서 관리하므로 서버 재고 개념 전면 제거(ProductDoc 재고 필드, availableStock, 주문 예약/차감, stock/adjust 엔드포인트, order/status restock) - 상세 카탈로그·주문 DTO에 옵션 노출, product/upsert가 optionLabel/options 수용 - 시드/업로드 스크립트의 재고 필드 기록 제거, vitest 계약 테스트 추가
This commit is contained in:
parent
2e4d01f985
commit
fde21bf8c3
@ -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,
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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 ?? {};
|
||||
|
||||
@ -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<ProductDoc, "totalStock" | "reservedStock" | "safetyStock">) { return product.totalStock - product.reservedStock - product.safetyStock; }
|
||||
export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { 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; }
|
||||
|
||||
@ -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<ProductDoc, "options">, 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<string, DocumentSnapshot>(); 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<ProductDoc> = { 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<string, unknown> = { 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 };
|
||||
});
|
||||
|
||||
@ -41,6 +41,14 @@ export async function getCatalogProduct(id: string): Promise<ProductDetailDto |
|
||||
return {
|
||||
...catalogSummary(product),
|
||||
detailImages: product.detailImages,
|
||||
// 옵션이 있는 상품만 라벨과 선택지를 내려보낸다. 필드를 하나씩 옮겨 담아
|
||||
// 문서에 다른 필드가 생겨도 응답으로 새지 않게 한다.
|
||||
...(product.options?.length
|
||||
? {
|
||||
optionLabel: product.optionLabel ?? "옵션",
|
||||
options: product.options.map((o) => ({ id: o.id, name: o.name })),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<OrderStatus, readonly OrderStatus[]> = {
|
||||
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;
|
||||
|
||||
37
tests/services/orderService.test.ts
Normal file
37
tests/services/orderService.test.ts
Normal file
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Record<string, unknown>> };
|
||||
expect(json.items[0]).not.toHaveProperty("optionId");
|
||||
expect(json.items[0]).not.toHaveProperty("optionName");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user