mmday-firebase/tests/services/statsService.test.ts
윤정민 513bf70e87 Reduce per-user reads in daily archive and stats
- dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다)
- 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백
- user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다)
- reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다)
- settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다
- 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다
- stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다
- precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다)
- 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일)
2026-07-27 13:26:41 +09:00

425 lines
14 KiB
TypeScript

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<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);
invalidateAllGameDays();
}
}
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"));
// 테스트는 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<boolean | null>
): Promise<void> {
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);
});
});