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`, }); }); });