mmday-firebase/tests/services/attendanceService.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

77 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DecodedIdToken } from "firebase-admin/auth";
import { firestore } from "../../src/firebase";
import { checkIn, getMonth } from "../../src/services/attendanceService";
import { AttendanceResult, PointLedgerType } from "../../src/types/panit";
import type { PointLedgerEntry, WalletDoc } from "../../src/types/points";
const uid = "att-user-1";
const token = { uid, firebase: { identities: {}, sign_in_provider: "google.com" } } as unknown as DecodedIdToken;
function at(iso: string, key: string) { const d = new Date(iso); vi.setSystemTime(d); return { clientAttemptedAt: d.toISOString(), clientIdempotencyKey: key }; }
async function ledger() { const s = await firestore.collection(`users/${uid}/pointLedger`).get(); return s.docs.map((d) => d.data() as PointLedgerEntry); }
async function wallet() { return (await firestore.doc(`users/${uid}/wallet/current`).get()).data() as WalletDoc; }
describe("attendanceService reward wallet", () => {
beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); });
afterEach(() => vi.useRealTimers());
it("첫 출석은 20P를 지급하고 같은 키 replay는 원장을 늘리지 않는다", async () => {
const body = at("2026-05-12T14:23:11+09:00", "one"); const first = await checkIn(token, body); const replay = await checkIn(token, body);
expect(first).toMatchObject({ result: AttendanceResult.CheckedIn, balanceAfter: 20, attendanceStreak: 1, pointsAwarded: [{ type: PointLedgerType.AttendanceDaily, amount: 20 }] });
expect(replay).toEqual(first); expect(await ledger()).toHaveLength(1); expect((await wallet()).availableBalance).toBe(20);
});
it("5일차 70P, 10일차 120P 스트릭 보너스를 지급한다", async () => {
let fifth; let tenth;
for (let day = 1; day <= 10; day++) { const result = await checkIn(token, at(`2026-05-${String(day).padStart(2, "0")}T12:00:00+09:00`, `k${day}`)); if (day === 5) fifth = result; if (day === 10) tenth = result; }
expect(fifth).toMatchObject({ balanceAfter: 150, attendanceStreak: 5 });
expect(tenth).toMatchObject({ balanceAfter: 350, attendanceStreak: 10 });
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak5)).toHaveLength(1);
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak10Interval)).toHaveLength(1);
});
it("결석 후 스트릭과 cycleStart를 재시작한다", async () => {
await checkIn(token, at("2026-05-01T12:00:00+09:00", "a")); const result = await checkIn(token, at("2026-05-03T12:00:00+09:00", "b"));
expect(result.attendanceStreak).toBe(1); expect((await firestore.doc(`users/${uid}/attendance/state`).get()).data()?.streakCycleStart).toBe("2026-05-03");
});
it("월 조회 잔액은 지갑에서 반환한다", async () => {
await checkIn(token, at("2026-05-12T12:00:00+09:00", "m")); expect(await getMonth(token, "2026-05")).toMatchObject({ attendedDays: [12], totalCount: 1, balance: 20 });
});
});
describe("attendanceService 응답 직렬화", () => {
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); });
afterEach(() => vi.useRealTimers());
it("신규 출석·중복 출석·멱등 replay 세 경로 모두 serverNow가 UTC ISO 문자열이다", async () => {
const fresh = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1"));
const replay = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1"));
const already = await checkIn(token, at("2026-05-12T15:00:00+09:00", "iso-2"));
expect(fresh.result).toBe(AttendanceResult.CheckedIn);
expect(already.result).toBe(AttendanceResult.AlreadyCheckedIn);
for (const res of [fresh, replay, already]) {
expect(typeof res.serverNow).toBe("string");
expect(res.serverNow).toMatch(UTC_ISO);
}
});
it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => {
const res = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-3"));
const json = JSON.stringify(res);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
});
it("서버와 시계가 어긋나면 409 CLOCK_SKEW 바디의 serverNow도 UTC ISO 문자열이다", async () => {
vi.setSystemTime(new Date("2026-05-12T14:23:11+09:00"));
const skewed = { clientAttemptedAt: new Date("2026-05-12T15:23:11+09:00").toISOString(), clientIdempotencyKey: "skew" };
const err = await checkIn(token, skewed).then(() => null, (e) => e);
expect(err).toMatchObject({ status: 409, code: "CLOCK_SKEW" });
expect(err.details.serverNow).toMatch(UTC_ISO);
});
});