mmday-firebase/tests/services/attendanceService.test.ts
윤정민 1101aedeb7 Implement attendance system with point rewards and push reminders.
- 출석 체크 시 데일리 포인트와 주간/월간 보너스를 지급하며, 멱등성 및 클럭 오차 처리를 포함한 로직을 구현했습니다.
- 포인트 이력 관리를 위한 `pointLedger` 컬렉션과 잔액 추적 리포지토리를 추가했습니다.
- 사용자별 알림 슬롯에 맞춰 FCM 출석 독려 푸시를 발송하는 스케줄러와 설정 API를 도입했습니다.
- RTDB의 희소 배열 응답을 7일 기준 배열로 정규화하여 통계 데이터의 일관성을 강화했습니다.
- 출석 시스템 전반에 대한 단위 및 통합 테스트를 추가하여 안정성을 확보했습니다.
2026-05-13 10:51:24 +09:00

344 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<PointLedgerEntry[]> {
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<AttendanceMonthDoc | null> {
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" });
});
});