- 지갑 문서(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 필드 삭제로 클라 응답 스키마 변경
42 lines
3.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|