- shipped 전환 시 운송사·송장번호(shipment) 필수 검증 및 shippedAt/deliveredAt 기록, POST /admin/order/shipment로 배송정보 정정(배송 시작 이력에 감사 정보 동기화) - refunded 전환 시 관리자 환불 사유(2~500자) 필수 — 주문 문서·상태 이력·포인트 원장(adminReason/adminActor)에 기록하고 DTO로 노출 - 상품 옵션에 active 필드 추가(구 문서는 활성 간주) — 비활성 옵션 주문은 OPTION_UNAVAILABLE 거부, 활성·교환 가능 상품은 활성 옵션 1개 이상 필요 - OrderDto/OrderStatusHistoryEntryDto/ProductOptionDto 확장 및 관련 테스트 보강 - 수정 파일 줄바꿈 LF 정규화 및 eslint --fix 적용
203 lines
8.0 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|