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