mmday-firebase/tests/services/statsService.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

234 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore, rtdb } from "../../src/firebase";
import { getStats } from "../../src/services/statsService";
import {
addDays,
todayKst,
type DateString,
} from "../../src/types/dateString";
import type {
Game,
GameStatus,
StatsResponse,
User,
} from "../../src/types/panit";
const uid = "stats-uid";
async function seedGames(
date: DateString,
specs: Array<{ status: GameStatus; winner?: string }>
): Promise<void> {
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
const gameId = `${date.replace(/-/g, "")}G${i}`;
const [y, m, d] = date.split("-").map(Number);
const doc: Game = {
time: Timestamp.fromDate(new Date(Date.UTC(y, m - 1, d, 9, 0))),
stadium: "잠실",
status: spec.status,
homeTeamCode: "LG",
awayTeamCode: "HT",
};
if (spec.winner) doc.winningTeamCode = spec.winner;
await firestore.collection("games").doc(gameId).set(doc);
}
}
async function seedUser(patch: Partial<User> = {}): Promise<void> {
await firestore
.collection("users")
.doc(uid)
.set(
{
displayName: "stats-user",
email: "s@e.com",
provider: "google",
knowledgeLevel: "beginner",
createdAt: Timestamp.now(),
...patch,
},
{ merge: true }
);
}
async function seedCache(
key: string,
partial: Partial<StatsResponse>
): Promise<void> {
await rtdb.ref(`/cache/stats/${uid}/${key}`).set(partial);
}
describe("statsService.getStats — 결석 lazy 보정", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
await firestore.recursiveDelete(firestore.collection("games"));
await rtdb.ref(`/cache/stats/${uid}`).remove();
await rtdb.ref(`/userVotes/${uid}`).remove();
});
it("lastJudgedDate가 이틀 이상 이전이고 gap에 실제 경기일이 있으면 streak 0", async () => {
const today = todayKst();
const threeDaysAgo = addDays(today, -3);
const twoDaysAgo = addDays(today, -2);
await seedUser({
currentStreak: 5,
highestStreak: 7,
lastJudgedDate: threeDaysAgo,
tierPoints: 100,
});
// gap에 실제 경기일을 둔다 → 결석으로 판정되어야 함.
await seedGames(twoDaysAgo, [
{ status: "completed", winner: "LG" },
{ status: "completed", winner: "LG" },
{ status: "completed", winner: "LG" },
]);
const stats = await getStats(uid);
expect(stats.streakDays).toBe(0);
expect(stats.highestStreak).toBe(7); // 최고 streak은 그대로
expect(stats.forDate).toBe(today);
});
it("gap에 실제 경기일이 없으면(전부 휴장) streak 유지", async () => {
const today = todayKst();
const threeDaysAgo = addDays(today, -3);
await seedUser({
currentStreak: 5,
highestStreak: 7,
lastJudgedDate: threeDaysAgo,
});
// games 컬렉션 비움 — 모든 gap 날이 0건이므로 skip 처리, 결석 아님.
const stats = await getStats(uid);
expect(stats.streakDays).toBe(5);
});
it("lastJudgedDate가 어제(D-1)면 streak 유지", async () => {
const today = todayKst();
const yesterday = addDays(today, -1);
await seedUser({
currentStreak: 5,
highestStreak: 5,
lastJudgedDate: yesterday,
});
const stats = await getStats(uid);
expect(stats.streakDays).toBe(5);
});
it("lastJudgedDate가 D-2여도 어제분 userVotes가 남아 있으면 archive 대기로 보고 streak 보호", async () => {
const today = todayKst();
const yesterday = addDays(today, -1);
const twoDaysAgo = addDays(today, -2);
await seedUser({
currentStreak: 5,
highestStreak: 5,
lastJudgedDate: twoDaysAgo,
});
// 어제 투표는 했으나 아직 dailyArchive가 안 돈 상황을 시뮬레이트.
await rtdb
.ref(`/userVotes/${uid}/${yesterday}/g1`)
.set({ team: "LG" });
const stats = await getStats(uid);
expect(stats.streakDays).toBe(5);
});
it("어제 경기가 있었는데 userVotes 비어 있으면(진짜 결석) streak 0", async () => {
const today = todayKst();
const yesterday = addDays(today, -1);
const twoDaysAgo = addDays(today, -2);
await seedUser({
currentStreak: 5,
highestStreak: 5,
lastJudgedDate: twoDaysAgo,
});
// 어제 실제 경기가 있었음 + userVotes 없음 = 진짜 결석.
await seedGames(yesterday, [
{ status: "completed", winner: "LG" },
{ status: "completed", winner: "LG" },
{ status: "completed", winner: "LG" },
]);
const stats = await getStats(uid);
expect(stats.streakDays).toBe(0);
});
it("lastJudgedDate가 없는 신규 유저는 streak 0", async () => {
await seedUser({
tierPoints: 0,
});
const stats = await getStats(uid);
expect(stats.streakDays).toBe(0);
});
});
describe("statsService.getStats — 캐시 forDate 검증", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
await rtdb.ref(`/cache/stats/${uid}`).remove();
await rtdb.ref(`/userVotes/${uid}`).remove();
});
it("캐시 forDate가 오늘이면 그대로 반환한다", async () => {
const today = todayKst();
await seedUser({ currentStreak: 99, lastJudgedDate: today });
// 일부러 streakDays를 비현실적인 값으로 캐시에 저장 → 캐시 hit이면 그대로 노출
await seedCache("current", {
streakDays: 12345,
highestStreak: 12345,
weeklyResults: [null, null, null, null, null, null, null],
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
totalPredictions: 0,
totalCorrect: 0,
weeklyPredictions: 0,
currentLevel: 1,
progress: 0,
tier: "bronze",
tierPoints: 0,
updatedAt: Date.now(),
forDate: today,
});
const stats = await getStats(uid);
expect(stats.streakDays).toBe(12345);
});
it("캐시 forDate가 어제면 무효화하고 재계산한다", async () => {
const today = todayKst();
const yesterday = addDays(today, -1);
await seedUser({ currentStreak: 3, lastJudgedDate: yesterday });
// 어제 날짜의 stale 캐시를 심어둔다.
await seedCache("current", {
streakDays: 99,
highestStreak: 99,
weeklyResults: [null, null, null, null, null, null, null],
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
totalPredictions: 0,
totalCorrect: 0,
weeklyPredictions: 0,
currentLevel: 1,
progress: 0,
tier: "bronze",
tierPoints: 0,
updatedAt: Date.now() - 86_400_000,
forDate: yesterday,
});
const stats = await getStats(uid);
expect(stats.forDate).toBe(today);
expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산
});
});