mmday-firebase/tests/types/rewardDto.test.ts
윤정민 2e4d01f985 Normalize pre-migration ledger documents in the response
- 포인트 시스템 개편(2026-07-16) 이전 스키마 문서가 운영 Firestore에 그대로 남아 있어 포인트 내역이 실패하던 문제 수정
- 구 문서에는 txId/uid/op/available*/reserved*가 없고 balanceAfter/refMonth/refDay가 있다
- 읽기 타입을 StoredLedgerEntry로 넓히고, DTO에서 남은 값으로 복원한다
  - txId는 문서 id, uid는 요청 uid, op는 type에서, 거래 전 잔액은 balanceAfter에서 역산
  - refMonth + refDay를 relatedDate(YYYY-MM-DD)로 합침
- 실제 운영 문서 형태를 그대로 쓴 회귀 테스트 추가
2026-07-20 20:50:57 +09:00

200 lines
7.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import {
EMPTY_WALLET_DTO,
toLedgerEntryDto,
toOrderDto,
toWalletDto,
} from "../../src/types/dto/rewardDto";
import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points";
import type { OrderDoc } 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,
reservedBalance: 300,
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,
reservedBefore: 300,
reservedAfter: 300,
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: "reserved",
clientIdempotencyKey: "key-1",
reserveLedgerTxIds: ["uid:order:o1:reserve"],
orderedAt: ts("2026-07-20T05:30:00.000Z"),
statusHistory: [{ status: "reserved", 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,
reservedBalance: 300,
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,
reservedBalance: 0,
totalEarned: 0,
totalSpent: 0,
version: 0,
});
});
});
describe("toLedgerEntryDto — 개편 전 스키마 문서", () => {
// 운영 Firestore 에 남아 있는 실제 형태 (users/{uid}/pointLedger, 자동 ID).
// txId/uid/op/available*/reserved* 가 없다.
const legacyDoc = {
type: PointLedgerType.AttendanceDaily,
amount: 10,
balanceAfter: 40,
refMonth: "2026-07",
refDay: 13,
createdAt: ts("2026-07-13T04:36:26.958Z"),
};
it("필수 필드가 없어도 응답을 만들어 낸다 (한 건 때문에 화면 전체가 죽지 않도록)", () => {
const dto = toLedgerEntryDto("0J4PNDtrJDZujvaCjO5G", "uid-1", legacyDoc);
expectNoTimestampLeak(dto);
expect(dto.txId).toBe("0J4PNDtrJDZujvaCjO5G");
expect(dto.uid).toBe("uid-1");
expect(dto.createdAt).toMatch(UTC_ISO);
});
it("op 를 type 에서 복원한다", () => {
expect(toLedgerEntryDto("legacy", "uid-1", legacyDoc).op).toBe("credit");
});
it("balanceAfter 로 거래 전후 잔액을 복원한다", () => {
const dto = toLedgerEntryDto("legacy", "uid-1", legacyDoc);
expect(dto.availableAfter).toBe(40);
expect(dto.availableBefore).toBe(30);
expect(dto.reservedBefore).toBe(0);
expect(dto.reservedAfter).toBe(0);
});
it("refMonth + refDay 를 relatedDate 로 합친다", () => {
expect(toLedgerEntryDto("legacy", "uid-1", legacyDoc).relatedDate).toBe("2026-07-13");
});
it("응답에 필수 필드가 하나도 빠지지 않는다 (클라이언트가 as String 캐스트를 한다)", () => {
const json = wire(toLedgerEntryDto("legacy", "uid-1", legacyDoc));
for (const key of [
"id", "txId", "uid", "type", "op", "amount",
"availableBefore", "availableAfter", "reservedBefore", "reservedAfter", "createdAt",
]) {
expect(json[key], `$key 누락`).not.toBeUndefined();
}
});
});
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("선택 날짜 필드가 채워지면 모두 문자열로 나간다", () => {
const settled: OrderDoc = {
...baseOrder,
status: "refunded",
cancelledBy: "admin",
cancelledAt: ts("2026-07-21T00:00:00.000Z"),
confirmedAt: ts("2026-07-22T00:00:00.000Z"),
refundedAt: ts("2026-07-23T00:00:00.000Z"),
};
const dto = toOrderDto("o1", settled);
expectNoTimestampLeak(dto);
for (const value of [dto.cancelledAt, dto.confirmedAt, dto.refundedAt]) {
expect(value).toMatch(UTC_ISO);
}
});
it("선택 날짜 필드가 비면 응답에서 키가 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("cancelledAt");
expect(json).not.toHaveProperty("confirmedAt");
expect(json).not.toHaveProperty("refundedAt");
expect(json).not.toHaveProperty("cancelledBy");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc;
expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly");
});
});