mmday-firebase/tests/services/orderService.test.ts
윤정민 9a66d3fb12 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 적용
2026-07-22 17:46:45 +09:00

203 lines
8.0 KiB
TypeScript

import { beforeAll, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import {
createOrder,
resolveOrderOption,
transitionOrder,
updateOrderShipment,
} from "../../src/services/orderService";
import { HttpError } from "../../src/middleware/errors";
const options = [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드", active: false },
];
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,
);
});
});
const uid = "order-service-test-user";
const seedNow = Timestamp.now();
const recipient = { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" };
beforeAll(async () => {
// 잔액 1000P 지갑과 활성 상품을 시드한다. (불변식: available = earned - spent)
await firestore.doc(`users/${uid}/wallet/current`).set({
availableBalance: 1000, totalEarned: 1000, totalSpent: 0,
version: 1, createdAt: seedNow, updatedAt: seedNow,
});
await firestore.doc("products/ost-product").set({
name: "테스트 키링", pointPrice: 300, active: true, redeemable: true,
mainImages: [], detailImages: [], displayOrder: 1, createdAt: seedNow, updatedAt: seedNow,
});
});
describe("createOrder (즉시 차감)", () => {
it("주문이 confirmed 로 생성되고 포인트가 즉시 차감된다", async () => {
const result = await createOrder(uid, {
clientIdempotencyKey: "immediate-1",
items: [{ productId: "ost-product", qty: 1 }],
recipient,
});
expect(result.order.status).toBe("confirmed");
expect(result.order.confirmedAt).toBeTruthy();
expect(result.order.debitLedgerTxId).toBe(`${uid}:order:${result.order.id}:debit`);
expect(result.availableBalance).toBe(700);
const entry = await firestore.doc(`users/${uid}/pointLedger/${result.order.debitLedgerTxId}`).get();
expect(entry.exists).toBe(true);
expect(entry.data()).toMatchObject({ type: "order_capture", op: "debit", amount: 300 });
});
it("잔액이 부족하면 409", async () => {
await expect(
createOrder(uid, {
clientIdempotencyKey: "too-expensive",
items: [{ productId: "ost-product", qty: 100 }],
recipient,
}),
).rejects.toMatchObject({ status: 409 });
});
});
describe("transitionOrder (간소화된 전이)", () => {
it("유저(비어드민)는 어떤 전이도 할 수 없다", async () => {
await expect(transitionOrder(`${uid}_immediate-1`, "shipped", uid)).rejects.toMatchObject({ status: 403 });
});
it("전이 테이블에 없는 전이는 409 (confirmed → delivered 직행 불가)", async () => {
await expect(
transitionOrder(`${uid}_immediate-1`, "delivered", "admin-uid", { admin: true }),
).rejects.toMatchObject({ status: 409 });
});
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, "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 });
const refundEntry = await firestore.doc(`users/${uid}/pointLedger/${uid}:order:${orderId}:refund`).get();
expect(refundEntry.exists).toBe(true);
expect(refundEntry.data()).toMatchObject({
type: "order_refund", op: "credit", amount: 300,
reversalOf: `${uid}:order:${orderId}:debit`,
adminReason: "상품 파손으로 교환 취소",
adminActor: "admin-uid",
});
});
});