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 { updateNotifications } from "../../src/services/userService"; import { AttendanceResult, PointLedgerType, type AttendanceMonthDoc, type PointLedgerEntry, } from "../../src/types/panit"; const uid = "att-user-1"; function fakeToken(): DecodedIdToken { return { uid, email: "att@example.com", firebase: { identities: {}, sign_in_provider: "google.com" }, aud: "test", auth_time: 0, exp: 0, iat: 0, iss: "test", sub: uid, } as DecodedIdToken; } /** 주어진 KST 날짜+시각의 절대 Date. */ function kstDate(iso: string): Date { return new Date(iso); } /** * KST 시각으로 시스템 클럭을 고정하고 valid한 client body를 생성한다. * clientAttemptedAt은 UTC ISO 8601 문자열(`Z` 접미사) — Flutter의 * `DateTime.now().toUtc().toIso8601String()`과 동일 형태. */ function bodyAt(now: Date, key = "k1") { return { clientAttemptedAt: now.toISOString(), // 항상 'Z' 접미사 포함 clientIdempotencyKey: key, }; } async function readLedger(): Promise { const snap = await firestore .collection("users") .doc(uid) .collection("pointLedger") .orderBy("createdAt", "asc") .orderBy("seq", "asc") .get(); return snap.docs.map((d) => d.data() as PointLedgerEntry); } async function readMonthDoc(month: string): Promise { const snap = await firestore .collection("users") .doc(uid) .collection("attendance") .doc(month) .get(); return snap.exists ? (snap.data() as AttendanceMonthDoc) : null; } describe("attendanceService", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users").doc(uid)); vi.useFakeTimers({ toFake: ["Date"] }); }); afterEach(() => { vi.useRealTimers(); }); describe("checkIn", () => { it("첫 출석 → checkedIn, daily +10, ledger 1건", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); const result = await checkIn(fakeToken(), bodyAt(now)); expect(result.result).toBe(AttendanceResult.CheckedIn); expect(result.attendedDays).toEqual([12]); expect(result.totalCount).toBe(1); expect(result.balanceAfter).toBe(10); expect(result.pointsAwarded).toEqual([ { type: PointLedgerType.AttendanceDaily, amount: 10 }, ]); const ledger = await readLedger(); expect(ledger).toHaveLength(1); expect(ledger[0].balanceAfter).toBe(10); expect(ledger[0].refMonth).toBe("2026-05"); expect(ledger[0].refDay).toBe(12); const month = await readMonthDoc("2026-05"); expect(month?.days).toEqual([12]); expect(month?.lastIdempotencyKey).toBe("k1"); }); it("같은 idempotencyKey 재호출 → 동일 결과, ledger 추가 X", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); const r1 = await checkIn(fakeToken(), bodyAt(now, "k1")); const r2 = await checkIn(fakeToken(), bodyAt(now, "k1")); expect(r2).toEqual(r1); expect(await readLedger()).toHaveLength(1); }); it("같은 날 다른 idempotencyKey → alreadyCheckedIn, 보상 없음", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); await checkIn(fakeToken(), bodyAt(now, "k1")); const r2 = await checkIn(fakeToken(), bodyAt(now, "k2")); expect(r2.result).toBe(AttendanceResult.AlreadyCheckedIn); expect(r2.pointsAwarded).toEqual([]); expect(r2.balanceAfter).toBe(10); expect(r2.attendedDays).toEqual([12]); expect(await readLedger()).toHaveLength(1); }); it("TZ 표기 없는 ISO 문자열 → 400 INVALID_INPUT", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); await expect( checkIn(fakeToken(), { clientAttemptedAt: "2026-05-12T14:23:11", // TZ 없음 clientIdempotencyKey: "k1", }) ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); }); it("clientAttemptedAt이 서버보다 6분 미래면 409 CLOCK_SKEW + 시각 details", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); const future = new Date(now.getTime() + 6 * 60 * 1000); await expect( checkIn(fakeToken(), bodyAt(future, "k1")) ).rejects.toMatchObject({ status: 409, code: "CLOCK_SKEW", details: { skewMs: 6 * 60 * 1000, }, }); }); it("월~토 6일 출석 후 일요일 → daily + weekly_bonus, ledger 2건", async () => { // 2026-05-04(월) ~ 2026-05-09(토) 출석 후 2026-05-10(일). for (let day = 4; day <= 9; day++) { const t = kstDate( `2026-05-${String(day).padStart(2, "0")}T10:00:00+09:00` ); vi.setSystemTime(t); await checkIn(fakeToken(), bodyAt(t, `seed-${day}`)); } const sunday = kstDate("2026-05-10T10:00:00+09:00"); vi.setSystemTime(sunday); const result = await checkIn(fakeToken(), bodyAt(sunday, "sun")); expect(result.pointsAwarded).toEqual([ { type: PointLedgerType.AttendanceDaily, amount: 10 }, { type: PointLedgerType.AttendanceWeeklyBonus, amount: 50 }, ]); expect(result.balanceAfter).toBe(7 * 10 + 50); const ledger = await readLedger(); expect(ledger).toHaveLength(8); // 6 daily + (daily + weekly) on Sunday expect(ledger[ledger.length - 1].type).toBe( PointLedgerType.AttendanceWeeklyBonus ); expect(ledger[ledger.length - 1].balanceAfter).toBe(120); }); it("주가 월 경계를 걸치면 weekly_bonus 미발급 (단순화 정책)", async () => { // 2026-06-01(월) ~ 2026-06-06(토) 출석 후 2026-06-07(일). // 6월에 월~토 6일 + 일요일 7일째지만, 정책상 OK이므로 weekly 발급. // 반례 테스트: 2026-05-25(월)~2026-05-31(일) 한 주는 같은 달 안에 들어가므로 발급. // 더 명확한 반례: 2026-08-31이 월요일이라면 그 주 일요일은 9월 6일. // 2026-08-31은 실제로 월요일임. for (let day = 31; day <= 31; day++) { const t = kstDate(`2026-08-${day}T10:00:00+09:00`); vi.setSystemTime(t); await checkIn(fakeToken(), bodyAt(t, `seed-aug-${day}`)); } for (let day = 1; day <= 5; day++) { const t = kstDate( `2026-09-${String(day).padStart(2, "0")}T10:00:00+09:00` ); vi.setSystemTime(t); await checkIn(fakeToken(), bodyAt(t, `seed-sep-${day}`)); } // 일요일 9/6 const sunday = kstDate("2026-09-06T10:00:00+09:00"); vi.setSystemTime(sunday); const result = await checkIn(fakeToken(), bodyAt(sunday, "sun-cross")); // weekly_bonus는 9월 days 안에 1~5만 있으므로 (8/31 월은 9월 doc에 없음) // isWeekFullyAttended 통과 못 함. expect( result.pointsAwarded.find( (a) => a.type === PointLedgerType.AttendanceWeeklyBonus ) ).toBeUndefined(); }); it("그 달 1~말일 모두 출석 → monthly_bonus 추가", async () => { // 2026-02 (28일). 2/1 ~ 2/27까지 출석한 뒤 2/28에 트리거. for (let day = 1; day <= 27; day++) { const t = kstDate( `2026-02-${String(day).padStart(2, "0")}T10:00:00+09:00` ); vi.setSystemTime(t); await checkIn(fakeToken(), bodyAt(t, `feb-${day}`)); } const lastDay = kstDate("2026-02-28T10:00:00+09:00"); vi.setSystemTime(lastDay); const result = await checkIn(fakeToken(), bodyAt(lastDay, "feb-28")); const monthly = result.pointsAwarded.find( (a) => a.type === PointLedgerType.AttendanceMonthlyBonus ); expect(monthly).toEqual({ type: PointLedgerType.AttendanceMonthlyBonus, amount: 100, }); // 28 daily + (weekly 발급되는 주 수만큼) + 100 monthly. // 2026-02 weekly 발급되는 일요일: 2/1, 2/8, 2/15, 2/22 (모두 같은 달, 직전 6일이 1월에 걸쳐있는 2/1만 별도). // 그러나 2/1은 일요일인데 1/26~1/31이 같은 달이 아니라 weekly 미발급. // 2/8 발급 (2~7 모두 같은 달), 2/15, 2/22 발급. 2/28(토)는 일요일 아님. // weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530. expect(result.balanceAfter).toBe(530); }); it("attendanceReminderSlot이 user doc에 30분 round-up 후 저장된다", async () => { const now = kstDate("2026-05-12T14:23:11+09:00"); vi.setSystemTime(now); await checkIn(fakeToken(), bodyAt(now)); const userSnap = await firestore.collection("users").doc(uid).get(); expect(userSnap.data()?.attendanceReminderSlot).toBe("14:30"); }); it("늦은 시각(22:35)은 22:00으로 클립된다", async () => { const now = kstDate("2026-05-12T22:35:00+09:00"); vi.setSystemTime(now); await checkIn(fakeToken(), bodyAt(now)); const userSnap = await firestore.collection("users").doc(uid).get(); expect(userSnap.data()?.attendanceReminderSlot).toBe("22:00"); }); }); describe("getMonth", () => { it("출석 기록 없는 월 → 404 MONTH_NOT_FOUND", async () => { await expect(getMonth(fakeToken(), "2026-05")).rejects.toMatchObject({ status: 404, code: "MONTH_NOT_FOUND", }); }); it("형식 오류 → 400 INVALID_INPUT", async () => { await expect(getMonth(fakeToken(), "2026-5")).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT", }); }); it("정상 월 조회 → days, totalCount, balance 반환", async () => { const t = kstDate("2026-05-12T10:00:00+09:00"); vi.setSystemTime(t); await checkIn(fakeToken(), bodyAt(t)); vi.useRealTimers(); const m = await getMonth(fakeToken(), "2026-05"); expect(m.month).toBe("2026-05"); expect(m.attendedDays).toEqual([12]); expect(m.totalCount).toBe(1); expect(m.balance).toBe(10); }); }); }); describe("userService.updateNotifications", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users").doc(uid)); await firestore.collection("users").doc(uid).set({ displayName: "tester", email: "x@x.com", provider: "google", knowledgeLevel: "casual", }); }); it("attendance:true 토글 → merge 응답", async () => { const result = await updateNotifications(fakeToken(), { notifications: { attendance: true }, }); expect(result).toEqual({ attendance: true }); const user = await firestore.collection("users").doc(uid).get(); expect(user.data()?.notifications).toEqual({ attendance: true }); }); it("whitelist 외 키 → 400", async () => { await expect( updateNotifications(fakeToken(), { notifications: { unknown: true } as never, }) ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); }); it("boolean 아닌 값 → 400", async () => { await expect( updateNotifications(fakeToken(), { notifications: { attendance: "yes" } as never, }) ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); }); it("notifications 자체가 없으면 → 400", async () => { await expect( updateNotifications(fakeToken(), {} as never) ).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" }); }); it("미존재 유저 → 404", async () => { await firestore.collection("users").doc(uid).delete(); await expect( updateNotifications(fakeToken(), { notifications: { attendance: true } }) ).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND" }); }); });