mmday-firebase/tests/scheduled/dailyArchive.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

130 lines
4.8 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 { runDailyArchive } from "../../src/scheduled/dailyArchive";
import type { DateString } from "../../src/types/dateString";
import type { Game, User } from "../../src/types/panit";
const date = "2026-05-12" as DateString;
const gameId = "20260512HTLG0";
async function seedGame(): Promise<void> {
const doc: Game = {
time: Timestamp.fromDate(new Date(Date.UTC(2026, 4, 12, 9, 0))),
stadium: "잠실",
status: "completed",
homeTeamCode: "LG",
awayTeamCode: "HT",
winningTeamCode: "LG",
};
await firestore.collection("games").doc(gameId).set(doc);
invalidateAllGameDays();
}
async function seedUser(uid: string): Promise<void> {
const user: Partial<User> = {
displayName: uid,
email: `${uid}@e.com`,
provider: "google",
knowledgeLevel: "casual",
active: true,
createdAt: Timestamp.now(),
};
await firestore.collection("users").doc(uid).set(user);
}
/** 판정이 끝난 투표를 원본에 심는다. reconcile 경로를 타지 않게 result를 채운다. */
async function seedRawVote(uid: string): Promise<void> {
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).set({ team: "LG", result: true });
}
/** 날짜별 인덱스에도 심는다(= 미러 배포 이후에 투표한 유저). */
async function seedIndexedVote(uid: string): Promise<void> {
await rtdb.ref(`/userVotesByDate/${date}/${uid}/${gameId}`).set({ team: "LG", result: true });
}
async function hasHistory(uid: string): Promise<boolean> {
const snap = await firestore
.collection("users").doc(uid)
.collection("voteHistory").doc(date)
.get();
return snap.exists;
}
describe("runDailyArchive — 날짜 인덱스 롤아웃", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
await firestore.recursiveDelete(firestore.collection("games"));
invalidateAllGameDays();
await rtdb.ref("/userVotes").remove();
await rtdb.ref("/userVotesByDate").remove();
await rtdb.ref("/userVotesByDateMeta").remove();
await seedGame();
});
/**
* 미러 배포 당일에는 인덱스가 부분적으로만 찬다 — 배포 전 투표자는 원본에만,
* 배포 후 투표자는 양쪽에 있다. 인덱스가 비지 않았다는 이유로 원본을 건너뛰면
* 배포 전 투표자가 영구 유실된다(그 날짜는 다시 처리되지 않는다).
*/
it("인덱스가 부분적으로만 찼으면 원본에서 누락 유저를 보충한다", async () => {
await seedUser("before-deploy");
await seedUser("after-deploy");
// 배포 전 투표자 — 원본에만 존재
await seedRawVote("before-deploy");
// 배포 후 투표자 — 원본 + 인덱스
await seedRawVote("after-deploy");
await seedIndexedVote("after-deploy");
const result = await runDailyArchive(date);
expect(result.archived).toBe(2);
expect(result.judgedUids.sort()).toEqual(["after-deploy", "before-deploy"]);
expect(await hasHistory("before-deploy")).toBe(true);
expect(await hasHistory("after-deploy")).toBe(true);
});
it("인덱스가 완전히 비어도 원본만으로 아카이브한다", async () => {
await seedUser("legacy-only");
await seedRawVote("legacy-only");
const result = await runDailyArchive(date);
expect(result.archived).toBe(1);
expect(await hasHistory("legacy-only")).toBe(true);
});
it("백필 마커가 있으면 인덱스만 신뢰한다(원본 전체 스캔 안 함)", async () => {
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
await seedUser("indexed");
await seedUser("stale-raw");
await seedIndexedVote("indexed");
await seedRawVote("indexed");
// 인덱스에 없는 원본 잔재 — 백필 완료 후에는 보충 대상이 아니다
await seedRawVote("stale-raw");
const result = await runDailyArchive(date);
expect(result.judgedUids).toEqual(["indexed"]);
expect(await hasHistory("stale-raw")).toBe(false);
});
it("양쪽 모두 비어 있으면 아무것도 아카이브하지 않는다", async () => {
const result = await runDailyArchive(date);
expect(result.archived).toBe(0);
expect(result.judgedUids).toEqual([]);
});
it("아카이브 후 원본과 날짜별 미러를 모두 정리한다", async () => {
await seedUser("cleanup");
await seedRawVote("cleanup");
await seedIndexedVote("cleanup");
await runDailyArchive(date);
expect((await rtdb.ref(`/userVotes/cleanup/${date}`).get()).exists()).toBe(false);
expect((await rtdb.ref(`/userVotesByDate/${date}/cleanup`).get()).exists()).toBe(false);
});
});