- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 confirmed 로 시작 — 상태 흐름을 확정 → 배송중 → 배송완료(+어드민 환불)로 간소화 - 유저 취소 엔드포인트(POST /orders/:id/cancel)와 cancelOrder 제거 — 상태 전이는 어드민 전용, 되돌림은 환불 전이 하나로 통일 (차감 txId 를 reversalOf 로 링크) - order_reserve/order_release 원장 타입과 reserve/capture/release op, 지갑 reservedBalance 제거 — 불변식을 available = earned - spent 로 단순화 - 이전 데이터 전면 삭제에 따라 2026-07 개편 이전 원장 문서용 레거시 정규화(StoredLedgerEntry, balanceAfter 역산, refMonth/refDay 합성)도 제거 - 주문/원장/지갑 테스트를 새 스키마로 갱신하고 orderService 에뮬레이터 테스트 6건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
211 lines
7.5 KiB
TypeScript
211 lines
7.5 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",
|
|
refundedAt: ts("2026-07-23T00:00:00.000Z"),
|
|
};
|
|
const dto = toOrderDto("o1", settled);
|
|
expectNoTimestampLeak(dto);
|
|
expect(dto.confirmedAt).toMatch(UTC_ISO);
|
|
expect(dto.refundedAt).toMatch(UTC_ISO);
|
|
});
|
|
|
|
it("환불 전에는 refundedAt 키가 응답에서 사라진다", () => {
|
|
const json = wire(toOrderDto("o1", baseOrder));
|
|
expect(json).not.toHaveProperty("refundedAt");
|
|
});
|
|
|
|
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: "블루" }]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|