import { beforeEach, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { firestore, rtdb } from "../../src/firebase"; import { invalidateAllGameDays } from "../../src/repositories/gameRepository"; 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); invalidateAllGameDays(); } } 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")); // 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다. invalidateAllGameDays(); 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 기준으로 재계산 }); }); /** * 통산 집계는 voteHistory 전수 스캔을 대체하므로, 백필 값과 이후 증분이 * 스캔 결과와 동일해야 한다(이중 계상·누락 없이). */ describe("statsService.getStats — 통산 롤링 집계", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await rtdb.ref(`/cache/stats/${uid}`).remove(); await rtdb.ref(`/userVotes/${uid}`).remove(); }); async function seedHistory( date: DateString, results: Array ): Promise { await firestore .collection("users").doc(uid) .collection("voteHistory").doc(date) .set({ data: results.map((result, i) => ({ gameId: `${date.replace(/-/g, "")}G${i}`, team: "LG", // null = 취소 무효표(result 없음) — 승률 모집단에서 제외되어야 한다 ...(result === null ? { cancelled: true } : { result }), })), judgment: "success", }); } it("카운터가 없으면 전수 스캔으로 산출하고 user doc에 백필한다", async () => { const today = todayKst(); await seedUser(); await seedHistory(addDays(today, -2), [true, false, true]); await seedHistory(addDays(today, -1), [true, null]); const stats = await getStats(uid); // [true,false,true] + [true, 취소] → 유효표 4건, 적중 3건 (취소 무효표 제외) expect(stats.totalPredictions).toBe(4); expect(stats.totalCorrect).toBe(3); expect(stats.winRates.overall).toBeCloseTo(3 / 4); const user = (await firestore.collection("users").doc(uid).get()).data()!; expect(user.lifetimePredictions).toBe(4); expect(user.lifetimeCorrect).toBe(3); // 기준선은 스캔에 포함된 마지막 날짜 expect(user.lifetimeStatsThrough).toBe(addDays(today, -1)); }); it("백필된 카운터가 있으면 그 값을 그대로 쓴다(재스캔 없음)", async () => { await seedUser({ lifetimePredictions: 40, lifetimeCorrect: 25, lifetimeStatsThrough: addDays(todayKst(), -1), }); // 카운터를 쓰는지 확인하려고 이력과 어긋나는 값을 심는다 await seedHistory(addDays(todayKst(), -2), [true]); const stats = await getStats(uid); expect(stats.totalPredictions).toBe(40); expect(stats.totalCorrect).toBe(25); }); /** * 이력이 없을 때 기준선을 오늘로 잡으면, 아직 아카이브되지 않은 어제 투표가 * `date > through` 조건에 걸려 영구히 누락되고 stale 검사로도 복구되지 않는다. */ it("이력이 없으면 기준선을 세우지 않는다", async () => { await seedUser(); const stats = await getStats(uid); expect(stats.totalPredictions).toBe(0); const user = (await firestore.collection("users").doc(uid).get()).data()!; expect(user.lifetimeStatsThrough).toBeUndefined(); expect(user.lifetimePredictions).toBeUndefined(); }); it("아카이브 전 조회 후 어제가 판정돼도 그날 예측이 누락되지 않는다", async () => { const yesterday = addDays(todayKst(), -1); await seedUser(); // 1) 어제 처음 투표한 유저가 아카이브(03:00) 전에 통계를 조회한다 await getStats(uid); // 2) 이후 아카이브가 어제를 판정해 voteHistory를 기록한다 await seedHistory(yesterday, [true, false]); await firestore.collection("users").doc(uid) .set({ lastJudgedDate: yesterday }, { merge: true }); await rtdb.ref(`/cache/stats/${uid}`).remove(); // 3) 다시 조회하면 어제 예측이 통산에 반영돼 있어야 한다 const stats = await getStats(uid); expect(stats.totalPredictions).toBe(2); expect(stats.totalCorrect).toBe(1); }); it("기준선을 lastJudgedDate로 앞당기지 않는다", async () => { const today = todayKst(); const d1 = addDays(today, -2); const d2 = addDays(today, -1); // 판정 트랜잭션은 커밋됐지만(setDay 이전) voteHistory에는 d2가 아직 없는 상태 await seedUser({ lastJudgedDate: d2 }); await seedHistory(d1, [true]); await getStats(uid); // 기준선이 d2로 올라가면 d2 예측이 영영 집계되지 않는다 — d1이어야 한다 const user = (await firestore.collection("users").doc(uid).get()).data()!; expect(user.lifetimeStatsThrough).toBe(d1); }); it("기준선이 lastJudgedDate보다 뒤처지면 재스캔해 자가치유한다", async () => { const today = todayKst(); const d1 = addDays(today, -2); const d2 = addDays(today, -1); // 백필이 d1까지만 반영된 상태에서 d2 판정이 증분되지 못한 상황을 재현한다 // (백필 스캔이 d2 문서를 보기 전에 판정 트랜잭션이 커밋되면 발생한다) await seedUser({ lifetimePredictions: 1, lifetimeCorrect: 1, lifetimeStatsThrough: d1, lastJudgedDate: d2, }); await seedHistory(d1, [true]); await seedHistory(d2, [true, false]); const stats = await getStats(uid); // 재스캔되어 d2까지 반영돼야 한다 expect(stats.totalPredictions).toBe(3); expect(stats.totalCorrect).toBe(2); const user = (await firestore.collection("users").doc(uid).get()).data()!; expect(user.lifetimeStatsThrough).toBe(d2); }); it("기준선이 lastJudgedDate와 같으면 재스캔하지 않는다", async () => { const d = addDays(todayKst(), -1); await seedUser({ lifetimePredictions: 40, lifetimeCorrect: 25, lifetimeStatsThrough: d, lastJudgedDate: d, }); await seedHistory(d, [true]); // 카운터와 어긋나는 이력을 심어도 무시돼야 한다 const stats = await getStats(uid); expect(stats.totalPredictions).toBe(40); }); }); describe("statsService.getStats — period 검증·정규화", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await rtdb.ref(`/cache/stats/${uid}`).remove(); await seedUser(); }); it("서비스 개시 이전 연도는 거부한다", async () => { await expect(getStats(uid, "1999")).rejects.toThrow(/invalid period/); }); it("미래 연도는 거부한다", async () => { const nextYear = Number(todayKst().slice(0, 4)) + 1; await expect(getStats(uid, String(nextYear))).rejects.toThrow(/invalid period/); }); it("잘못된 월은 거부한다", async () => { const year = todayKst().slice(0, 4); await expect(getStats(uid, `${year}-13`)).rejects.toThrow(/invalid period/); }); it("미래 날짜는 거부한다", async () => { await expect(getStats(uid, addDays(todayKst(), 1))).rejects.toThrow(/invalid period/); }); it("같은 주의 서로 다른 날짜는 하나의 캐시 키로 접힌다", async () => { const today = todayKst(); await getStats(uid, today); const keys = Object.keys( (await rtdb.ref(`/cache/stats/${uid}`).get()).val() ?? {} ); // 원본 날짜 문자열이 아니라 주 단위 정규형(w<화요일>)으로 저장된다 expect(keys.some((k) => k.startsWith("w"))).toBe(true); expect(keys).not.toContain(today); }); });