mmday-firebase/tests/types/rewardDto.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

277 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import {
EMPTY_WALLET_DTO,
toAdminProductDto,
toLedgerEntryDto,
toOrderDto,
toWalletDto,
} from "../../src/types/dto/rewardDto";
import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points";
import type { OrderDoc, ProductDoc } from "../../src/types/reward";
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const ts = (iso: string) => Timestamp.fromDate(new Date(iso));
/** 와이어에 실제로 나가는 모양. Timestamp 유출은 여기서만 드러난다. */
function wire(dto: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(dto));
}
/** 중첩 구조 어디에도 admin SDK Timestamp 의 내부 필드가 남아 있지 않은지 확인. */
function expectNoTimestampLeak(dto: unknown): void {
const json = JSON.stringify(dto);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
}
const wallet: WalletDoc = {
availableBalance: 1200,
totalEarned: 5000,
totalSpent: 3500,
version: 7,
createdAt: ts("2026-01-02T03:04:05.678Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
const ledgerEntry: PointLedgerEntry = {
txId: "uid:2026-07-20:attendance_daily",
uid: "uid",
type: PointLedgerType.AttendanceDaily,
op: "credit",
amount: 20,
availableBefore: 1180,
availableAfter: 1200,
relatedDate: "2026-07-20",
createdAt: ts("2026-07-20T05:30:00.000Z"),
};
const baseOrder: OrderDoc = {
uid: "uid",
items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }],
totalPoints: 1000,
recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" },
status: "confirmed",
clientIdempotencyKey: "key-1",
debitLedgerTxId: "uid:order:o1:debit",
orderedAt: ts("2026-07-20T05:30:00.000Z"),
confirmedAt: ts("2026-07-20T05:30:00.000Z"),
statusHistory: [{ status: "confirmed", at: ts("2026-07-20T05:30:00.000Z"), actor: "uid" }],
createdAt: ts("2026-07-20T05:30:00.000Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
describe("toWalletDto", () => {
it("날짜를 UTC ISO 문자열로 내보내고 Timestamp 를 유출하지 않는다", () => {
const dto = toWalletDto(wallet);
expectNoTimestampLeak(dto);
expect(wire(dto)).toEqual({
availableBalance: 1200,
totalEarned: 5000,
totalSpent: 3500,
version: 7,
createdAt: "2026-01-02T03:04:05.678Z",
updatedAt: "2026-07-20T05:30:00.000Z",
});
});
it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => {
expect(wire(EMPTY_WALLET_DTO)).toEqual({
availableBalance: 0,
totalEarned: 0,
totalSpent: 0,
version: 0,
});
});
});
describe("toLedgerEntryDto", () => {
it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => {
const dto = toLedgerEntryDto("entry-1", "uid", ledgerEntry);
expectNoTimestampLeak(dto);
expect(dto.id).toBe("entry-1");
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.createdAt).toBe("2026-07-20T05:30:00.000Z");
});
it("relatedDate 의 YYYY-MM-DD 형식은 그대로 둔다", () => {
expect(toLedgerEntryDto("entry-1", "uid", ledgerEntry).relatedDate).toBe("2026-07-20");
});
it("선택 필드가 없으면 응답에서 키가 사라진다", () => {
const minimal: PointLedgerEntry = { ...ledgerEntry, relatedDate: undefined };
const json = wire(toLedgerEntryDto("entry-1", "uid", minimal));
expect(json).not.toHaveProperty("relatedDate");
expect(json).not.toHaveProperty("orderId");
});
});
describe("toOrderDto", () => {
it("중첩된 statusHistory 까지 UTC ISO 문자열로 바꾼다", () => {
const dto = toOrderDto("o1", baseOrder);
expectNoTimestampLeak(dto);
expect(dto.orderedAt).toMatch(UTC_ISO);
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.updatedAt).toMatch(UTC_ISO);
expect(dto.statusHistory[0].at).toMatch(UTC_ISO);
expect(dto.statusHistory[0].at).toBe("2026-07-20T05:30:00.000Z");
});
it("환불 주문은 refundedAt 도 문자열로 나간다", () => {
const settled: OrderDoc = {
...baseOrder,
status: "refunded",
refundReason: "고객 요청으로 교환 취소",
refundedAt: ts("2026-07-23T00:00:00.000Z"),
statusHistory: [
...baseOrder.statusHistory,
{
status: "refunded",
at: ts("2026-07-23T00:00:00.000Z"),
actor: "admin-uid",
refundReason: "고객 요청으로 교환 취소",
},
],
};
const dto = toOrderDto("o1", settled);
expectNoTimestampLeak(dto);
expect(dto.confirmedAt).toMatch(UTC_ISO);
expect(dto.refundedAt).toMatch(UTC_ISO);
expect(dto.refundReason).toBe("고객 요청으로 교환 취소");
expect(dto.statusHistory[1].refundReason).toBe("고객 요청으로 교환 취소");
});
it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("refundedAt");
expect(json).not.toHaveProperty("refundReason");
});
it("배송 정보와 배송 시각을 명시적으로 내보낸다", () => {
const shippedAt = ts("2026-07-21T02:00:00.000Z");
const shipmentUpdatedAt = ts("2026-07-21T03:00:00.000Z");
const shipped: OrderDoc = {
...baseOrder,
status: "shipped",
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shippedAt,
statusHistory: [
...baseOrder.statusHistory,
{
status: "shipped",
at: shippedAt,
actor: "shipping-admin",
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shipmentUpdatedAt,
shipmentUpdatedBy: "shipment-editor",
},
],
};
const json = wire(toOrderDto("o1", shipped));
expect(json.shipment).toEqual({ carrier: "CJ대한통운", trackingNumber: "1234567890" });
expect(json.shippedAt).toBe("2026-07-21T02:00:00.000Z");
expect(json.statusHistory[1]).toMatchObject({
shipment: { carrier: "CJ대한통운", trackingNumber: "1234567890" },
shipmentUpdatedAt: "2026-07-21T03:00:00.000Z",
shipmentUpdatedBy: "shipment-editor",
});
});
it("배송 전에는 배송 정보와 배송 시각 키가 응답에서 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("shipment");
expect(json).not.toHaveProperty("shippedAt");
expect(json).not.toHaveProperty("deliveredAt");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc;
expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly");
});
it("옵션 스냅샷이 있으면 항목에 optionId/optionName 을 그대로 내보낸다", () => {
const withOption: OrderDoc = {
...baseOrder,
items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" }],
};
const json = wire(toOrderDto("o1", withOption));
expect(json.items).toEqual([
{ productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" },
]);
});
it("옵션이 없는 항목은 응답에서 optionId/optionName 키가 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder)) as { items: Array<Record<string, unknown>> };
expect(json.items[0]).not.toHaveProperty("optionId");
expect(json.items[0]).not.toHaveProperty("optionName");
});
});
describe("toAdminProductDto", () => {
const baseProduct: ProductDoc = {
name: "아크릴 키링",
pointPrice: 3000,
active: false,
redeemable: false,
displayOrder: 1,
mainImages: ["https://example.com/main.png"],
detailImages: ["https://example.com/detail.png"],
createdAt: ts("2026-01-01T00:00:00.000Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
it("active/redeemable 이 false 인 비노출 상품도 그대로 응답에 포함한다", () => {
const dto = toAdminProductDto("acrylic-keyring", baseProduct);
expect(dto.active).toBe(false);
expect(dto.redeemable).toBe(false);
});
it("Timestamp 를 유출하지 않고 UTC ISO(Z) 문자열로 내보낸다", () => {
const dto = toAdminProductDto("acrylic-keyring", baseProduct);
expectNoTimestampLeak(dto);
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.updatedAt).toMatch(UTC_ISO);
expect(dto.createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(dto.updatedAt).toBe("2026-07-20T05:30:00.000Z");
});
it("옵션이 있으면 optionLabel/options 를 포함한다", () => {
const withOptions: ProductDoc = {
...baseProduct,
optionLabel: "색상",
options: [{ id: "blue", name: "블루" }],
};
const json = wire(toAdminProductDto("p1", withOptions));
expect(json.optionLabel).toBe("색상");
expect(json.options).toEqual([{ id: "blue", name: "블루", active: true }]);
});
it("옵션 활성 상태를 어드민 응답에 명시한다", () => {
const withOptions: ProductDoc = {
...baseProduct,
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드", active: false },
],
};
const json = wire(toAdminProductDto("p1", withOptions));
expect(json.options).toEqual([
{ id: "blue", name: "블루", active: true },
{ id: "red", name: "레드", active: false },
]);
});
it("옵션이 없으면 optionLabel/options 키가 응답에서 사라진다", () => {
const json = wire(toAdminProductDto("p1", baseProduct));
expect(json).not.toHaveProperty("optionLabel");
expect(json).not.toHaveProperty("options");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...baseProduct, internalOnly: "secret" } as ProductDoc;
expect(wire(toAdminProductDto("p1", polluted))).not.toHaveProperty("internalOnly");
});
});