mmday-firebase/tests/services/scoreboardService.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

342 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { firestore, rtdb } from "../../src/firebase";
import {
__resetScoreboardCaches,
getScoreboard,
} from "../../src/services/scoreboardService";
import {
precomputeScoreboardCache,
buildRankStandings,
snapshotRankForUser,
} from "../../src/services/rankSnapshotService";
import { todayKst, type DateString } from "../../src/types/dateString";
import { TeamCode, type RankSnapshot } from "../../src/types/panit";
interface SeedUser {
uid: string;
displayName: string;
tierPoints?: number;
favoriteTeamCode?: TeamCode;
photoUrl?: string;
rankSnapshot?: RankSnapshot;
}
async function seedUser(u: SeedUser): Promise<void> {
const doc: Record<string, unknown> = {
displayName: u.displayName,
email: `${u.uid}@example.com`,
provider: "google",
knowledgeLevel: "casual",
// 신규 가입 기본값과 동일 — 랭킹 쿼리의 active 필터를 통과해야 한다.
active: true,
};
if (u.tierPoints !== undefined) doc.tierPoints = u.tierPoints;
if (u.favoriteTeamCode) doc.favoriteTeamCode = u.favoriteTeamCode;
if (u.photoUrl) doc.photoUrl = u.photoUrl;
if (u.rankSnapshot) doc.rankSnapshot = u.rankSnapshot;
await firestore.collection("users").doc(u.uid).set(doc);
}
describe("scoreboardService.getScoreboard", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
await rtdb.ref("/scoreboardCache").remove();
__resetScoreboardCaches();
});
describe("type=overall", () => {
it("RTDB 캐시 기반 top 10과 me(rank/delta/percentile)를 반환한다", async () => {
for (let i = 1; i <= 12; i++) {
await seedUser({
uid: `u${i}`,
displayName: `유저${i}`,
tierPoints: i * 10,
rankSnapshot: { date: "2026-04-21" as DateString, overall: 20 - i },
});
}
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("u5", "overall");
expect(res.type).toBe("overall");
expect(res.teamCode).toBeUndefined();
expect(res.totalCount).toBe(12);
expect(res.top).toHaveLength(10);
expect(res.top[0].uid).toBe("u12");
expect(res.top[0].rank).toBe(1);
expect(res.top[0].percentile).toBe(100);
expect(res.top[9].uid).toBe("u3");
expect(res.top[9].rank).toBe(10);
// u5 rankSnapshot.overall = 15, currentRank = 8 → delta = 7
expect(res.me).not.toBeNull();
expect(res.me!.uid).toBe("u5");
expect(res.me!.rank).toBe(8);
expect(res.me!.rankDelta).toBe(7);
// percentile = round((1 - 7/12) * 100) = round(41.67) = 42
expect(res.me!.percentile).toBe(42);
});
it("tierPoints가 없거나 0이면 me=null", async () => {
await seedUser({ uid: "u1", displayName: "유저1", tierPoints: 100 });
await seedUser({ uid: "me", displayName: "나" });
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "overall");
expect(res.me).toBeNull();
expect(res.top[0].uid).toBe("u1");
});
it("rankSnapshot 없으면 rankDelta=null", async () => {
await seedUser({ uid: "me", displayName: "나", tierPoints: 50 });
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "overall");
expect(res.me!.rankDelta).toBeNull();
expect(res.top[0].rankDelta).toBeNull();
});
it("동점자는 rank가 같다 (competition ranking)", async () => {
await seedUser({ uid: "a", displayName: "A", tierPoints: 100 });
await seedUser({ uid: "b", displayName: "B", tierPoints: 50 });
await seedUser({ uid: "me", displayName: "나", tierPoints: 50 });
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "overall");
// top[0]=a rank1, top[1]=b or me (동점 rank2), top[2] 다른 동점자 rank2
expect(res.top[0].rank).toBe(1);
expect(res.top[1].rank).toBe(2);
expect(res.top[2].rank).toBe(2);
expect(res.me!.rank).toBe(2);
});
it("유저 doc이 없으면 404", async () => {
await precomputeScoreboardCache(todayKst());
await expect(getScoreboard("nobody", "overall")).rejects.toMatchObject({
status: 404,
});
});
it("RTDB 캐시가 없으면 503", async () => {
await seedUser({ uid: "me", displayName: "나", tierPoints: 100 });
await expect(getScoreboard("me", "overall")).rejects.toMatchObject({
status: 503,
});
});
it("photoUrl이 top/me에 포함된다", async () => {
await seedUser({
uid: "me",
displayName: "나",
tierPoints: 100,
photoUrl: "https://cdn/p.png",
});
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "overall");
expect(res.top[0].photoUrl).toBe("https://cdn/p.png");
expect(res.me!.photoUrl).toBe("https://cdn/p.png");
});
});
describe("type=team", () => {
it("같은 favoriteTeamCode 유저만 포함하고 team rank로 계산한다", async () => {
await seedUser({
uid: "lg1",
displayName: "LG1",
tierPoints: 100,
favoriteTeamCode: TeamCode.LG,
rankSnapshot: { date: "2026-04-21" as DateString, overall: 5, team: 3, teamCode: TeamCode.LG },
});
await seedUser({
uid: "lg2",
displayName: "LG2",
tierPoints: 50,
favoriteTeamCode: TeamCode.LG,
});
await seedUser({
uid: "kt1",
displayName: "KT1",
tierPoints: 200,
favoriteTeamCode: TeamCode.KT,
});
await seedUser({
uid: "me",
displayName: "나",
tierPoints: 80,
favoriteTeamCode: TeamCode.LG,
rankSnapshot: { date: "2026-04-21" as DateString, overall: 7, team: 5, teamCode: TeamCode.LG },
});
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "team");
expect(res.type).toBe("team");
expect(res.teamCode).toBe(TeamCode.LG);
expect(res.totalCount).toBe(3);
expect(res.top.map((e) => e.uid)).toEqual(["lg1", "me", "lg2"]);
// me team rank = 2 (above: lg1), delta = 5 - 2 = 3
expect(res.me!.rank).toBe(2);
expect(res.me!.rankDelta).toBe(3);
});
it("팀 변경 시 team delta는 null", async () => {
await seedUser({
uid: "me",
displayName: "나",
tierPoints: 80,
favoriteTeamCode: TeamCode.NC,
rankSnapshot: { date: "2026-04-21" as DateString, overall: 1, team: 1, teamCode: TeamCode.LG },
});
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "team");
expect(res.me!.rankDelta).toBeNull();
});
it("favoriteTeamCode가 없으면 400", async () => {
await seedUser({ uid: "me", displayName: "나", tierPoints: 100 });
await precomputeScoreboardCache(todayKst());
await expect(getScoreboard("me", "team")).rejects.toMatchObject({
status: 400,
});
});
it("팀 유저가 본인뿐이면 top=[me], rank=1, percentile=100", async () => {
await seedUser({
uid: "me",
displayName: "나",
tierPoints: 30,
favoriteTeamCode: TeamCode.NC,
});
await seedUser({
uid: "other",
displayName: "타인",
tierPoints: 999,
favoriteTeamCode: TeamCode.KT,
});
await precomputeScoreboardCache(todayKst());
const res = await getScoreboard("me", "team");
expect(res.top).toHaveLength(1);
expect(res.top[0].uid).toBe("me");
expect(res.me!.rank).toBe(1);
expect(res.me!.percentile).toBe(100);
});
});
});
describe("rankSnapshotService.snapshotRankForUser", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
});
it("overall/team rank를 rankSnapshot에 기록한다", async () => {
await firestore.collection("users").doc("a").set({
displayName: "A", email: "a@e", provider: "google", knowledgeLevel: "casual",
active: true, tierPoints: 200, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("b").set({
displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual",
active: true, tierPoints: 100, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("c").set({
displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual",
active: true, tierPoints: 150, favoriteTeamCode: TeamCode.KT,
});
await snapshotRankForUser("b", "2026-04-22" as DateString);
const doc = await firestore.collection("users").doc("b").get();
const snap = doc.data()!.rankSnapshot as RankSnapshot;
expect(snap.date).toBe("2026-04-22");
// b tierPoints=100, above 전체: a(200), c(150) → 2 → overall rank=3
expect(snap.overall).toBe(3);
// team=LG 내 above: a → 1 → team rank=2
expect(snap.team).toBe(2);
expect(snap.teamCode).toBe(TeamCode.LG);
});
it("tierPoints=0이면 스냅샷을 남기지 않는다", async () => {
await firestore.collection("users").doc("zero").set({
displayName: "Z", email: "z@e", provider: "google", knowledgeLevel: "casual",
active: true, tierPoints: 0,
});
await snapshotRankForUser("zero", "2026-04-22" as DateString);
const doc = await firestore.collection("users").doc("zero").get();
expect(doc.data()!.rankSnapshot).toBeUndefined();
});
it("favoriteTeamCode가 없으면 team 필드 없이 overall만 기록", async () => {
await firestore.collection("users").doc("solo").set({
displayName: "S", email: "s@e", provider: "google", knowledgeLevel: "casual",
active: true, tierPoints: 50,
});
await snapshotRankForUser("solo", "2026-04-22" as DateString);
const doc = await firestore.collection("users").doc("solo").get();
const snap = doc.data()!.rankSnapshot as RankSnapshot;
expect(snap.overall).toBe(1);
expect(snap.team).toBeUndefined();
expect(snap.teamCode).toBeUndefined();
});
});
/**
* `buildRankStandings`는 유저별 count aggregation을 대체하므로, aggregation과
* 동일한 순위 정의("초과 인원 + 1", 동점자 동일 순위)를 지켜야 한다.
*/
describe("rankSnapshotService.buildRankStandings", () => {
const users = [
{ tierPoints: 200, favoriteTeamCode: TeamCode.LG },
{ tierPoints: 150, favoriteTeamCode: TeamCode.KT },
{ tierPoints: 100, favoriteTeamCode: TeamCode.LG },
{ tierPoints: 100, favoriteTeamCode: TeamCode.LG },
{ tierPoints: 50 },
];
it("overall 순위는 '초과 인원 + 1'이다", () => {
const s = buildRankStandings(users);
expect(s.rankOf(200)).toBe(1);
expect(s.rankOf(150)).toBe(2);
expect(s.rankOf(100)).toBe(3);
expect(s.rankOf(50)).toBe(5);
});
it("동점자는 같은 순위를 받는다", () => {
const s = buildRankStandings(users);
// 100점이 2명 → 둘 다 3위, 그 아래 50점은 5위
expect(s.rankOf(100)).toBe(3);
expect(s.rankOf(99)).toBe(5);
});
it("팀 스코프는 해당 팀 유저만 센다", () => {
const s = buildRankStandings(users);
// LG: 200, 100, 100
expect(s.rankOf(200, TeamCode.LG)).toBe(1);
expect(s.rankOf(100, TeamCode.LG)).toBe(2);
// KT: 150 하나뿐
expect(s.rankOf(150, TeamCode.KT)).toBe(1);
});
it("해당 팀 유저가 없으면 1위로 계산한다", () => {
const s = buildRankStandings(users);
expect(s.rankOf(10, TeamCode.HH)).toBe(1);
});
it("빈 순위표에서도 1위를 돌려준다", () => {
const s = buildRankStandings([]);
expect(s.rankOf(0)).toBe(1);
});
it("무작위 입력에서 선형 스캔 결과와 일치한다", () => {
const rand = [3, 17, 17, 2, 99, 41, 41, 41, 8, 60].map((tierPoints) => ({ tierPoints }));
const s = buildRankStandings(rand);
for (const { tierPoints } of rand) {
const linear = rand.filter((u) => u.tierPoints > tierPoints).length + 1;
expect(s.rankOf(tierPoints)).toBe(linear);
}
});
});