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