mmday-firebase/tests/services/attendanceService.test.ts
윤정민 c964572a9c Replace point ledger with wallet engine and rework reward earning
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
2026-07-16 14:25:42 +09:00

42 lines
3.0 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 { 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 });
});
});