- 출석 독려 푸시 알림 기능과 관련 스케줄러(attendanceReminder)를 삭제했습니다.(클라이언트로 이관) - 유저 데이터에서 알림 슬롯 필드를 제거하고, 출석 시 수행하던 슬롯 계산 및 저장 로직을 제거했습니다. - 주간 보너스 확인 로직을 리팩토링하고, getMonth 함수 내 비동기 데이터 조회를 병렬화하여 성능을 개선했습니다.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
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" });
|
||
});
|
||
});
|