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