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 { 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 = {}): Promise { 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 ): Promise { 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, tickets: { dailyAllKill: 0, weeklyMaster: 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, tickets: { dailyAllKill: 0, weeklyMaster: 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 기준으로 재계산 }); });