mmday-firebase/tests/services/orderService.test.ts
윤정민 85edc7c7e3 Simplify orders to immediate debit with single refund path
- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 confirmed 로 시작 — 상태 흐름을 확정 → 배송중 → 배송완료(+어드민 환불)로 간소화
- 유저 취소 엔드포인트(POST /orders/:id/cancel)와 cancelOrder 제거 — 상태 전이는 어드민 전용, 되돌림은 환불 전이 하나로 통일 (차감 txId 를 reversalOf 로 링크)
- order_reserve/order_release 원장 타입과 reserve/capture/release op, 지갑 reservedBalance 제거 — 불변식을 available = earned - spent 로 단순화
- 이전 데이터 전면 삭제에 따라 2026-07 개편 이전 원장 문서용 레거시 정규화(StoredLedgerEntry, balanceAfter 역산, refMonth/refDay 합성)도 제거
- 주문/원장/지갑 테스트를 새 스키마로 갱신하고 orderService 에뮬레이터 테스트 6건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
2026-07-21 16:06:13 +09:00

113 lines
4.6 KiB
TypeScript

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 { 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,
);
});
});
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("어드민 환불 시 포인트가 반환되고 차감 txId 가 reversalOf 로 링크된다", async () => {
const orderId = `${uid}_immediate-1`;
const result = await transitionOrder(orderId, "refunded", "admin-uid", { admin: true });
expect(result.status).toBe("refunded");
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`,
});
});
});