- 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 키 생략, 미지 필드 차단)
77 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|