mmday-firebase/tests/services/attendanceService.test.ts
윤정민 86b0da7c2d Remove attendance reminder system and optimize attendance logic.
- 출석 독려 푸시 알림 기능과 관련 스케줄러(attendanceReminder)를 삭제했습니다.(클라이언트로 이관)
- 유저 데이터에서 알림 슬롯 필드를 제거하고, 출석 시 수행하던 슬롯 계산 및 저장 로직을 제거했습니다.
- 주간 보너스 확인 로직을 리팩토링하고, getMonth 함수 내 비동기 데이터 조회를 병렬화하여 성능을 개선했습니다.
2026-05-18 14:19:39 +09:00

325 lines
11 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);
});
});
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" });
});
});