Add shipment, refund reason, and option availability to orders

- shipped 전환 시 운송사·송장번호(shipment) 필수 검증 및 shippedAt/deliveredAt 기록, POST /admin/order/shipment로 배송정보 정정(배송 시작 이력에 감사 정보 동기화)
- refunded 전환 시 관리자 환불 사유(2~500자) 필수 — 주문 문서·상태 이력·포인트 원장(adminReason/adminActor)에 기록하고 DTO로 노출
- 상품 옵션에 active 필드 추가(구 문서는 활성 간주) — 비활성 옵션 주문은 OPTION_UNAVAILABLE 거부, 활성·교환 가능 상품은 활성 옵션 1개 이상 필요
- OrderDto/OrderStatusHistoryEntryDto/ProductOptionDto 확장 및 관련 테스트 보강
- 수정 파일 줄바꿈 LF 정규화 및 eslint --fix 적용
This commit is contained in:
윤정민 2026-07-22 17:46:45 +09:00
parent 2bcbb3ca77
commit 9a66d3fb12
8 changed files with 398 additions and 40 deletions

View File

@ -10,7 +10,7 @@ import { listAllOrders } from "../repositories/orderRepository";
import { firestore } from "../firebase";
import { Timestamp } from "firebase-admin/firestore";
import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder } from "../services/orderService";
import { transitionOrder, updateOrderShipment } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService";
import { listAdminUsers, searchAdminUsers } from "../services/adminUserService";
import {
@ -33,7 +33,9 @@ function applyCors(req: { get(name: string): string | undefined }, res: { set(na
export const admin = onRequest(async (req, res) => {
applyCors(req, res);
// preflight에는 Authorization 헤더가 없어 requireAdmin보다 먼저 처리해야 한다.
if (req.method === "OPTIONS") { res.status(204).send(""); return; }
if (req.method === "OPTIONS") {
res.status(204).send(""); return;
}
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
const tail = segs.slice(-2).join("/");
@ -41,10 +43,16 @@ export const admin = onRequest(async (req, res) => {
try {
const adminUid = await requireAdmin(req);
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; }
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") {
const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return;
}
// 지갑/원장도 DTO 를 거쳐 Timestamp 가 {_seconds,_nanoseconds} 로 새지 않게 한다 (어드민 웹이 브라우저에서 소비).
if (tail === "points/wallet" && req.method === "GET") { const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return; }
if (tail === "points/ledger" && req.method === "GET") { const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return; }
if (tail === "points/wallet" && req.method === "GET") {
const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return;
}
if (tail === "points/ledger" && req.method === "GET") {
const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return;
}
if (tail === "product/upsert" && req.method === "POST") {
const {
id, name, pointPrice, active, redeemable, displayOrder,
@ -60,9 +68,11 @@ export const admin = onRequest(async (req, res) => {
(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
typeof o?.name === "string" && o.name.length >= 1 && o.name.length <= 100 &&
(o.active === undefined || typeof o.active === "boolean")
) &&
new Set(options.map((o) => o.id)).size === options.length);
const hasActiveOption = !Array.isArray(options) || options.some((o) => o?.active !== false);
const valid =
/^[A-Za-z0-9_-]{1,100}$/.test(id) &&
typeof name === "string" && name.length >= 1 && name.length <= 100 &&
@ -71,6 +81,7 @@ export const admin = onRequest(async (req, res) => {
Number.isSafeInteger(displayOrder) && displayOrder >= 0 &&
validUrls(mainImages, 10) && validUrls(detailImages, 30) &&
(!active || !redeemable || mainImages.length > 0) &&
(!active || !redeemable || hasActiveOption) &&
validOptions &&
(optionLabel === undefined ||
(typeof optionLabel === "string" && optionLabel.length >= 1 && optionLabel.length <= 50));
@ -83,12 +94,16 @@ export const admin = onRequest(async (req, res) => {
tx.set(productRef, {
name, pointPrice, active, redeemable, displayOrder,
mainImages, detailImages,
...(options !== undefined
? {
...(options !== undefined ?
{
optionLabel: optionLabel ?? "옵션",
options: options.map((o: { id: string; name: string }) => ({ id: o.id, name: o.name })),
}
: {}),
options: options.map((o: { id: string; name: string; active?: boolean }) => ({
id: o.id,
name: o.name,
active: o.active !== false,
})),
} :
{}),
createdAt: previous?.createdAt ?? now,
updatedAt: now,
});
@ -97,7 +112,20 @@ export const admin = onRequest(async (req, res) => {
res.json({ id });
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 === "order/status" && req.method === "POST") {
const { orderId, status, shipment, refundReason } = req.body ?? {};
res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, {
admin: true,
shipment,
refundReason,
}));
return;
}
if (tail === "order/shipment" && req.method === "POST") {
const { orderId, shipment } = req.body ?? {};
res.json(await updateOrderShipment(orderId, adminUid, shipment));
return;
}
if (tail === "users/search" && req.method === "GET") {
res.json(await searchAdminUsers(req.query.q, req.query.limit));

View File

@ -6,14 +6,52 @@ 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 OrderItem, type OrderStatus, type ProductDoc, type ProductOption, type Recipient } from "../types/reward";
import {
ORDER_TRANSITIONS,
type OrderDoc,
type OrderItem,
type OrderShipment,
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; 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"); }
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");
}
interface TransitionOrderOptions {
admin?: boolean;
shipment?: OrderShipment;
refundReason?: string;
}
function normalizeShipment(input: OrderShipment | undefined): OrderShipment {
const carrier = typeof input?.carrier === "string" ? input.carrier.trim() : "";
const trackingNumber = typeof input?.trackingNumber === "string" ?
input.trackingNumber.replace(/\s/g, "") : "";
if (carrier.length < 1 || carrier.length > 50 || !/^[A-Za-z0-9-]{4,100}$/.test(trackingNumber)) {
throw new HttpError(400, "invalid shipment", "INVALID_SHIPMENT");
}
return { carrier, trackingNumber };
}
function normalizeRefundReason(input: string | undefined): string {
const reason = typeof input === "string" ? input.trim() : "";
if (reason.length < 2 || reason.length > 500) {
throw new HttpError(400, "invalid refund reason", "INVALID_REFUND_REASON");
}
return reason;
}
/** 옵션 상품이면 주문 항목의 optionId 를 실제 옵션으로 해석하고, 무옵션 상품이면 optionId 를 거부한다. */
export function resolveOrderOption(product: Pick<ProductDoc, "options">, optionId: string | undefined): ProductOption | null {
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");
@ -21,6 +59,7 @@ export function resolveOrderOption(product: Pick<ProductDoc, "options">, optionI
}
const option = options.find((o) => o.id === optionId);
if (!option) throw new HttpError(400, "option required", "OPTION_REQUIRED");
if (option.active === false) throw new HttpError(409, "option unavailable", "OPTION_UNAVAILABLE");
return option;
}
@ -30,7 +69,9 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true };
// 같은 상품의 서로 다른 옵션이 별개 항목으로 들어올 수 있으므로 상품 문서는 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 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: OrderItem[] = input.items.map((item) => {
const p = snapById.get(item.productId)!.data() as ProductDoc | undefined;
@ -46,18 +87,98 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
});
}
export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean } = {}) {
export async function transitionOrder(
orderId: string,
target: OrderStatus,
actor: string,
opts: TransitionOrderOptions = {},
) {
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) throw new HttpError(403, "forbidden", "FORBIDDEN");
const shipment = target === "shipped" ? normalizeShipment(opts.shipment) : undefined;
const refundReason = target === "refunded" ? normalizeRefundReason(opts.refundReason) : undefined;
const now = Timestamp.now();
if (target === "refunded") {
await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:refund`, type: PointLedgerType.OrderRefund, amount: order.totalPoints, orderId, reversalOf: order.debitLedgerTxId }]);
await applyPointChangesTx(tx, order.uid, [{
txId: `${order.uid}:order:${orderId}:refund`,
type: PointLedgerType.OrderRefund,
amount: order.totalPoints,
orderId,
reversalOf: order.debitLedgerTxId,
adminReason: refundReason,
adminActor: actor,
}]);
}
const patch: Record<string, unknown> = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch);
const patch: Record<string, unknown> = {
status: target,
updatedAt: now,
statusHistory: [
...order.statusHistory,
{
status: target,
at: now,
actor,
...(shipment ? { shipment } : {}),
...(refundReason ? { refundReason } : {}),
},
],
};
if (target === "shipped") {
patch.shipment = shipment;
patch.shippedAt = now;
}
if (target === "delivered") patch.deliveredAt = now;
if (target === "refunded") {
patch.refundedAt = now;
patch.refundReason = refundReason;
}
tx.update(orderRef(orderId), patch);
return { id: orderId, status: target };
});
}
/** 배송 시작 이후 운송사·송장번호를 정정하고 배송 시작 이력에도 동기화한다. */
export async function updateOrderShipment(
orderId: string,
actor: string,
input: OrderShipment | undefined,
) {
if (typeof orderId !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) {
throw new HttpError(400, "invalid order id", "INVALID_INPUT");
}
const shipment = normalizeShipment(input);
return firestore.runTransaction(async (tx) => {
const ref = orderRef(orderId);
const snap = await tx.get(ref);
if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND");
const order = snap.data() as OrderDoc;
let shippedHistoryIndex = -1;
for (let index = order.statusHistory.length - 1; index >= 0; index -= 1) {
if (order.statusHistory[index].status === "shipped") {
shippedHistoryIndex = index;
break;
}
}
if (shippedHistoryIndex < 0) {
throw new HttpError(409, "shipment not registered", "SHIPMENT_NOT_REGISTERED");
}
const now = Timestamp.now();
const statusHistory = order.statusHistory.map((entry, index) =>
index === shippedHistoryIndex ?
{
...entry,
shipment,
shipmentUpdatedAt: now,
shipmentUpdatedBy: actor,
} :
entry
);
tx.update(ref, { shipment, statusHistory, updatedAt: now });
return { id: orderId, shipment };
});
}
export { getOrder, listOrders };

View File

@ -43,12 +43,16 @@ export async function getCatalogProduct(id: string): Promise<ProductDetailDto |
detailImages: product.detailImages,
// 옵션이 있는 상품만 라벨과 선택지를 내려보낸다. 필드를 하나씩 옮겨 담아
// 문서에 다른 필드가 생겨도 응답으로 새지 않게 한다.
...(product.options?.length
? {
...(product.options?.length ?
{
optionLabel: product.optionLabel ?? "옵션",
options: product.options.map((o) => ({ id: o.id, name: o.name })),
}
: {}),
options: product.options.map((o) => ({
id: o.id,
name: o.name,
active: o.active !== false,
})),
} :
{}),
};
});
}

View File

@ -64,6 +64,15 @@ export interface OrderStatusHistoryEntryDto {
status: OrderStatus;
at: string;
actor: string;
shipment?: OrderShipmentDto;
shipmentUpdatedAt?: string;
shipmentUpdatedBy?: string;
refundReason?: string;
}
export interface OrderShipmentDto {
carrier: string;
trackingNumber: string;
}
export interface OrderDto {
@ -74,11 +83,15 @@ export interface OrderDto {
recipient: RecipientDto;
status: OrderStatus;
clientIdempotencyKey: string;
shipment?: OrderShipmentDto;
refundReason?: string;
statusHistory: OrderStatusHistoryEntryDto[];
orderedAt: string;
createdAt: string;
updatedAt: string;
confirmedAt: string;
shippedAt?: string;
deliveredAt?: string;
refundedAt?: string;
}
@ -107,6 +120,7 @@ export interface ProductSummaryDto {
export interface ProductOptionDto {
id: string;
name: string;
active: boolean;
}
export interface ProductDetailDto extends ProductSummaryDto {
@ -186,12 +200,12 @@ export function toAdminProductDto(id: string, doc: ProductDoc): AdminProductDto
displayOrder: doc.displayOrder,
mainImages: doc.mainImages,
detailImages: doc.detailImages,
...(doc.options?.length
? {
...(doc.options?.length ?
{
optionLabel: doc.optionLabel ?? "옵션",
options: doc.options.map((o) => ({ id: o.id, name: o.name })),
}
: {}),
options: doc.options.map((o) => ({ id: o.id, name: o.name, active: o.active !== false })),
} :
{}),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
@ -224,15 +238,29 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
},
status: doc.status,
clientIdempotencyKey: doc.clientIdempotencyKey,
shipment: doc.shipment ? {
carrier: doc.shipment.carrier,
trackingNumber: doc.shipment.trackingNumber,
} : undefined,
refundReason: doc.refundReason,
statusHistory: doc.statusHistory.map((h) => ({
status: h.status,
at: toIso(h.at),
actor: h.actor,
shipment: h.shipment ? {
carrier: h.shipment.carrier,
trackingNumber: h.shipment.trackingNumber,
} : undefined,
shipmentUpdatedAt: toIsoOrUndefined(h.shipmentUpdatedAt),
shipmentUpdatedBy: h.shipmentUpdatedBy,
refundReason: h.refundReason,
})),
orderedAt: toIso(doc.orderedAt),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
confirmedAt: toIso(doc.confirmedAt),
shippedAt: toIsoOrUndefined(doc.shippedAt),
deliveredAt: toIsoOrUndefined(doc.deliveredAt),
refundedAt: toIsoOrUndefined(doc.refundedAt),
};
}

View File

@ -7,6 +7,8 @@ import type { Timestamp } from "firebase-admin/firestore";
export interface ProductOption {
id: string;
name: string;
/** 구 문서에는 필드가 없을 수 있으며, 그 경우 활성으로 간주한다. */
active?: boolean;
}
export interface ProductDoc {
@ -41,14 +43,33 @@ export interface OrderItem {
optionId?: string; optionName?: string;
}
export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
export interface OrderShipment {
carrier: string;
trackingNumber: string;
}
export interface OrderStatusHistoryEntry {
status: OrderStatus;
at: Timestamp;
actor: string;
/** 배송 시작 이력에 당시 배송정보를 함께 보관한다. */
shipment?: OrderShipment;
/** 배송정보를 나중에 수정한 경우의 감사 정보. */
shipmentUpdatedAt?: Timestamp;
shipmentUpdatedBy?: string;
/** 환불 처리 시 관리자가 입력한 사유. */
refundReason?: string;
}
export interface OrderDoc {
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus;
clientIdempotencyKey: string;
/** 주문 생성 시 즉시 차감한 원장 txId. 환불 시 reversalOf 링크로 쓴다. */
debitLedgerTxId: string;
orderedAt: Timestamp;
statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>;
confirmedAt: Timestamp; refundedAt?: Timestamp;
shipment?: OrderShipment;
/** 클라이언트가 처리이력 전체를 해석하지 않고 표시할 수 있는 최종 환불 사유. */
refundReason?: string;
statusHistory: OrderStatusHistoryEntry[];
confirmedAt: Timestamp; shippedAt?: Timestamp; deliveredAt?: Timestamp; refundedAt?: Timestamp;
createdAt: Timestamp; updatedAt: Timestamp;
}
export interface EligibilityResult {

View File

@ -1,12 +1,17 @@
import { beforeAll, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import { createOrder, resolveOrderOption, transitionOrder } from "../../src/services/orderService";
import {
createOrder,
resolveOrderOption,
transitionOrder,
updateOrderShipment,
} from "../../src/services/orderService";
import { HttpError } from "../../src/middleware/errors";
const options = [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
{ id: "red", name: "레드", active: false },
];
describe("resolveOrderOption", () => {
@ -94,11 +99,94 @@ describe("transitionOrder (간소화된 전이)", () => {
).rejects.toMatchObject({ status: 409 });
});
it("어드민 환불 시 포인트가 반환되고 차감 txId 가 reversalOf 로 링크된다", async () => {
it("비활성 옵션은 OPTION_UNAVAILABLE 로 거부한다", () => {
expect(() => resolveOrderOption({ options }, "red")).toThrowError(
expect.objectContaining({ status: 409, code: "OPTION_UNAVAILABLE" }) as HttpError,
);
});
it("배송 시작에는 택배사와 송장번호가 필수다", async () => {
await expect(
transitionOrder(`${uid}_immediate-1`, "shipped", "admin-uid", { admin: true }),
).rejects.toMatchObject({ status: 400, code: "INVALID_SHIPMENT" });
});
it("배송 시작 이력이 없는 주문의 배송정보는 수정할 수 없다", async () => {
await expect(
updateOrderShipment(`${uid}_immediate-1`, "admin-uid", {
carrier: "CJ대한통운",
trackingNumber: "1234567890",
}),
).rejects.toMatchObject({ status: 409, code: "SHIPMENT_NOT_REGISTERED" });
});
it("배송 시작 시 배송 정보와 발송 시각을 저장한다", async () => {
const orderId = `${uid}_immediate-1`;
const result = await transitionOrder(orderId, "refunded", "admin-uid", { admin: true });
const result = await transitionOrder(orderId, "shipped", "admin-uid", {
admin: true,
shipment: { carrier: " CJ대한통운 ", trackingNumber: "1234 5678-90" },
});
expect(result.status).toBe("shipped");
const order = await firestore.doc(`orders/${orderId}`).get();
expect(order.data()).toMatchObject({
status: "shipped",
shipment: { carrier: "CJ대한통운", trackingNumber: "12345678-90" },
statusHistory: expect.arrayContaining([
expect.objectContaining({
status: "shipped",
shipment: { carrier: "CJ대한통운", trackingNumber: "12345678-90" },
}),
]),
});
expect(order.data()?.shippedAt).toBeTruthy();
});
it("배송정보를 수정하면 주문과 배송 시작 이력을 함께 갱신한다", async () => {
const orderId = `${uid}_immediate-1`;
const result = await updateOrderShipment(orderId, "shipment-editor", {
carrier: " 우체국택배 ",
trackingNumber: "9876 5432-10",
});
expect(result.shipment).toEqual({ carrier: "우체국택배", trackingNumber: "98765432-10" });
const order = await firestore.doc(`orders/${orderId}`).get();
expect(order.data()?.shipment).toEqual({ carrier: "우체국택배", trackingNumber: "98765432-10" });
const shippedHistory = order.data()?.statusHistory.find(
(entry: { status: string }) => entry.status === "shipped",
);
expect(shippedHistory).toMatchObject({
shipment: { carrier: "우체국택배", trackingNumber: "98765432-10" },
shipmentUpdatedBy: "shipment-editor",
});
expect(shippedHistory.shipmentUpdatedAt).toBeTruthy();
});
it("환불 사유가 없으면 환불할 수 없다", async () => {
await expect(
transitionOrder(`${uid}_immediate-1`, "refunded", "admin-uid", { admin: true }),
).rejects.toMatchObject({ status: 400, code: "INVALID_REFUND_REASON" });
});
it("어드민 환불 시 사유를 저장하고 차감 포인트를 반환한다", async () => {
const orderId = `${uid}_immediate-1`;
const result = await transitionOrder(orderId, "refunded", "admin-uid", {
admin: true,
refundReason: " 상품 파손으로 교환 취소 ",
});
expect(result.status).toBe("refunded");
const order = await firestore.doc(`orders/${orderId}`).get();
expect(order.data()).toMatchObject({
refundReason: "상품 파손으로 교환 취소",
statusHistory: expect.arrayContaining([
expect.objectContaining({
status: "refunded",
refundReason: "상품 파손으로 교환 취소",
}),
]),
});
const wallet = await firestore.doc(`users/${uid}/wallet/current`).get();
expect(wallet.data()).toMatchObject({ availableBalance: 1000, totalSpent: 0 });
@ -107,6 +195,8 @@ describe("transitionOrder (간소화된 전이)", () => {
expect(refundEntry.data()).toMatchObject({
type: "order_refund", op: "credit", amount: 300,
reversalOf: `${uid}:order:${orderId}:debit`,
adminReason: "상품 파손으로 교환 취소",
adminActor: "admin-uid",
});
});
});

View File

@ -78,20 +78,20 @@ describe("rewardCatalogService option contract", () => {
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
{ id: "red", name: "레드", active: false },
],
createdAt: now,
updatedAt: now,
});
});
it("상세에는 옵션 라벨과 선택지를 문서 순서대로 노출한다", async () => {
it("상세에는 비활성 상태를 포함한 옵션을 문서 순서대로 노출한다", async () => {
expect(await getCatalogProduct(optionId)).toMatchObject({
id: optionId,
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
{ id: "blue", name: "블루", active: true },
{ id: "red", name: "레드", active: false },
],
});
});

View File

@ -122,17 +122,67 @@ describe("toOrderDto", () => {
const settled: OrderDoc = {
...baseOrder,
status: "refunded",
refundReason: "고객 요청으로 교환 취소",
refundedAt: ts("2026-07-23T00:00:00.000Z"),
statusHistory: [
...baseOrder.statusHistory,
{
status: "refunded",
at: ts("2026-07-23T00:00:00.000Z"),
actor: "admin-uid",
refundReason: "고객 요청으로 교환 취소",
},
],
};
const dto = toOrderDto("o1", settled);
expectNoTimestampLeak(dto);
expect(dto.confirmedAt).toMatch(UTC_ISO);
expect(dto.refundedAt).toMatch(UTC_ISO);
expect(dto.refundReason).toBe("고객 요청으로 교환 취소");
expect(dto.statusHistory[1].refundReason).toBe("고객 요청으로 교환 취소");
});
it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("refundedAt");
expect(json).not.toHaveProperty("refundReason");
});
it("배송 정보와 배송 시각을 명시적으로 내보낸다", () => {
const shippedAt = ts("2026-07-21T02:00:00.000Z");
const shipmentUpdatedAt = ts("2026-07-21T03:00:00.000Z");
const shipped: OrderDoc = {
...baseOrder,
status: "shipped",
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shippedAt,
statusHistory: [
...baseOrder.statusHistory,
{
status: "shipped",
at: shippedAt,
actor: "shipping-admin",
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shipmentUpdatedAt,
shipmentUpdatedBy: "shipment-editor",
},
],
};
const json = wire(toOrderDto("o1", shipped));
expect(json.shipment).toEqual({ carrier: "CJ대한통운", trackingNumber: "1234567890" });
expect(json.shippedAt).toBe("2026-07-21T02:00:00.000Z");
expect(json.statusHistory[1]).toMatchObject({
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shipmentUpdatedAt: "2026-07-21T03:00:00.000Z",
shipmentUpdatedBy: "shipment-editor",
});
});
it("배송 전에는 배송 정보와 배송 시각 키가 응답에서 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("shipment");
expect(json).not.toHaveProperty("shippedAt");
expect(json).not.toHaveProperty("deliveredAt");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
@ -194,7 +244,23 @@ describe("toAdminProductDto", () => {
};
const json = wire(toAdminProductDto("p1", withOptions));
expect(json.optionLabel).toBe("색상");
expect(json.options).toEqual([{ id: "blue", name: "블루" }]);
expect(json.options).toEqual([{ id: "blue", name: "블루", active: true }]);
});
it("옵션 활성 상태를 어드민 응답에 명시한다", () => {
const withOptions: ProductDoc = {
...baseProduct,
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드", active: false },
],
};
const json = wire(toAdminProductDto("p1", withOptions));
expect(json.options).toEqual([
{ id: "blue", name: "블루", active: true },
{ id: "red", name: "레드", active: false },
]);
});
it("옵션이 없으면 optionLabel/options 키가 응답에서 사라진다", () => {