mmday-firebase/tests/services/statsService.test.ts
윤정민 ffafca1338 Unify prediction ranking on baseball tier codes
- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
2026-07-23 14:13:19 +09:00

230 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,
tier: "BW",
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,
tier: "BW",
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 기준으로 재계산
});
});