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); }); });