mmday-firebase/tests/types/predictionStatsDto.test.ts
윤정민 107f75c23a Serialize API dates as UTC ISO strings via response DTOs
- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
2026-07-20 16:28:45 +09:00

91 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { toGameDto } from "../../src/types/dto/predictionDto";
import { EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto } from "../../src/types/dto/statsDto";
import type { Game, VoteHistoryDoc } from "../../src/types/panit";
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));
function wire(dto: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(dto));
}
function expectNoTimestampLeak(dto: unknown): void {
const json = JSON.stringify(dto);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
}
const game = {
gameId: "20260720LGHT0",
time: ts("2026-07-20T09:30:00.000Z"),
stadium: "잠실",
status: "scheduled",
homeTeamCode: "LG",
awayTeamCode: "HT",
} as Game & { gameId: string };
describe("toGameDto", () => {
it("time 을 UTC ISO 문자열로 바꾼다", () => {
const dto = toGameDto(game);
expectNoTimestampLeak(dto);
expect(dto.time).toMatch(UTC_ISO);
expect(dto.time).toBe("2026-07-20T09:30:00.000Z");
});
it("경기가 끝나지 않았으면 결과 관련 키가 응답에 없다", () => {
const json = wire(toGameDto(game));
expect(json).not.toHaveProperty("winningTeamCode");
expect(json).not.toHaveProperty("cancelReason");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...game, internalOnly: "secret" } as Game & { gameId: string };
expect(wire(toGameDto(polluted))).not.toHaveProperty("internalOnly");
});
});
describe("toVoteHistoryDto", () => {
const settled = {
data: [
{ gameId: "20260720LGHT0", team: "LG", result: true },
{ gameId: "20260720SSKT0", team: "SS", cancelled: true },
],
judgment: "success",
correctCount: 1,
completedCount: 2,
streakAfter: 3,
rewardSettledAt: ts("2026-07-21T00:15:00.000Z"),
rewardTotal: 70,
} as VoteHistoryDoc;
it("rewardSettledAt 을 UTC ISO 문자열로 바꾼다", () => {
const dto = toVoteHistoryDto(settled);
expectNoTimestampLeak(dto);
expect(dto.rewardSettledAt).toMatch(UTC_ISO);
expect(dto.rewardSettledAt).toBe("2026-07-21T00:15:00.000Z");
});
it("정산 전이면 rewardSettledAt 키가 응답에 없다", () => {
const pending = { ...settled, rewardSettledAt: undefined, rewardTotal: undefined } as VoteHistoryDoc;
const json = wire(toVoteHistoryDto(pending));
expect(json).not.toHaveProperty("rewardSettledAt");
expect(json).not.toHaveProperty("rewardTotal");
expect(json.data).toHaveLength(2);
});
it("문서가 없는 날은 빈 목록으로 응답한다", () => {
expect(wire(EMPTY_VOTE_HISTORY_DTO)).toEqual({ data: [] });
});
it("투표 항목의 미지 필드를 흘려보내지 않는다", () => {
const polluted = {
...settled,
data: [{ gameId: "g1", team: "LG", result: true, internalOnly: "secret" }],
} as VoteHistoryDoc;
expect(JSON.stringify(toVoteHistoryDto(polluted))).not.toContain("internalOnly");
});
});