Implement scoreboard system with rank snapshots and delta tracking.

- 전체 및 팀별 스코어보드 기능을 구현하고, 순위 변동(delta)을 시각화하기 위한 스냅샷 시스템을 도입했습니다.
- `dailyArchive` 과정에서 사용자의 현재 순위를 `rankSnapshot`으로 기록하여, 다음 판정 시점의 순위 변화량을 정확히 계산합니다.
- 상위권 데이터는 RTDB에 사전 계산(precompute)하여 저장함으로써 대규모 조회 요청에 대응하고, `MemCache`를 통해 API 응답 성능을 최적화했습니다.
- Firestore 복합 색인과 Count Aggregation을 활용하여 동점자 처리가 포함된 랭킹 및 상위 퍼센타일 산출 로직을 구현했습니다.
- 관련 서비스 레이어, 저장소 함수, API 핸들러 및 단위 테스트 코드를 추가했습니다.
This commit is contained in:
윤정민 2026-04-23 09:19:04 +09:00
parent 392ecfbd0b
commit b25e78dde8
11 changed files with 739 additions and 2 deletions

View File

@ -46,6 +46,15 @@
// ] // ]
// }, // },
// ] // ]
"indexes": [], "indexes": [
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
]
}
],
"fieldOverrides": [] "fieldOverrides": []
} }

View File

@ -1,6 +1,6 @@
import { onRequest } from "firebase-functions/https"; import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth.js"; import { requireAuth } from "../middleware/auth.js";
import { sendError } from "../middleware/errors.js"; import { HttpError, sendError } from "../middleware/errors.js";
import { import {
createPrediction, createPrediction,
updatePrediction, updatePrediction,
@ -8,6 +8,7 @@ import {
getSummary, getSummary,
listGamesByDate, listGamesByDate,
} from "../services/predictionService.js"; } from "../services/predictionService.js";
import { getScoreboard } from "../services/scoreboardService.js";
export const prediction = onRequest(async (req, res) => { export const prediction = onRequest(async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
@ -21,6 +22,18 @@ export const prediction = onRequest(async (req, res) => {
return; return;
} }
if (tail === "scoreboard" && req.method === "GET") {
const uid = await requireAuth(req);
const type = String(req.query.type ?? "");
if (type !== "team" && type !== "overall") {
throw new HttpError(400, `invalid type: ${type}`);
}
const result = await getScoreboard(uid, type);
res.set("Cache-Control", "private, max-age=60");
res.status(200).json(result);
return;
}
if (tail === "summary" && req.method === "GET") { if (tail === "summary" && req.method === "GET") {
const gameId = String(req.query.gameId ?? ""); const gameId = String(req.query.gameId ?? "");
const result = await getSummary(gameId); const result = await getSummary(gameId);

View File

@ -0,0 +1,36 @@
import { rtdb } from "../firebase.js";
import type { DateString } from "../types/dateString.js";
import type { TeamCode } from "../types/panit.js";
import type { ScoreboardScopeCache } from "../types/scoreboard.js";
const BASE = "/scoreboardCache";
export type Scope = { kind: "overall" } | { kind: "team"; teamCode: TeamCode };
function pathFor(date: DateString, scope: Scope): string {
if (scope.kind === "overall") return `${BASE}/${date}/overall`;
return `${BASE}/${date}/team/${scope.teamCode}`;
}
export async function readScope(
date: DateString,
scope: Scope
): Promise<ScoreboardScopeCache | null> {
const snap = await rtdb.ref(pathFor(date, scope)).get();
if (!snap.exists()) return null;
const raw = snap.val() as ScoreboardScopeCache;
// RTDB는 null 값을 저장하지 않아 read 시 필드가 누락된다 — 복원.
raw.top = (raw.top ?? []).map((e) => ({
...e,
rankDelta: e.rankDelta ?? null,
}));
return raw;
}
export async function writeScope(
date: DateString,
scope: Scope,
doc: ScoreboardScopeCache
): Promise<void> {
await rtdb.ref(pathFor(date, scope)).set(doc);
}

View File

@ -4,6 +4,7 @@ import type {
DailyJudgment, DailyJudgment,
KnowledgeLevel, KnowledgeLevel,
Provider, Provider,
RankSnapshot,
TeamCode, TeamCode,
User, User,
} from "../types/panit"; } from "../types/panit";
@ -31,6 +32,76 @@ export async function getUser(uid: string): Promise<User | null> {
return snap.exists ? (snap.data() as User) : null; return snap.exists ? (snap.data() as User) : null;
} }
export interface ScoreboardUserEntry {
uid: string;
displayName: string;
photoUrl?: string;
tierPoints: number;
favoriteTeamCode?: TeamCode;
rankSnapshot?: RankSnapshot;
}
/**
* `tierPoints` N명을 . `teamCode`
* . `tierPoints` .
* `rankSnapshot` delta .
*/
export async function listTopByTierPoints(
limit: number,
teamCode?: TeamCode
): Promise<ScoreboardUserEntry[]> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
const snap = await query
.orderBy("tierPoints", "desc")
.limit(limit)
.select(
"displayName",
"photoUrl",
"tierPoints",
"favoriteTeamCode",
"rankSnapshot"
)
.get();
return snap.docs.map((d) => {
const data = d.data() as Partial<User>;
const entry: ScoreboardUserEntry = {
uid: d.id,
displayName: data.displayName ?? "",
tierPoints: data.tierPoints ?? 0,
};
if (data.photoUrl) entry.photoUrl = data.photoUrl;
if (data.favoriteTeamCode) entry.favoriteTeamCode = data.favoriteTeamCode;
if (data.rankSnapshot) entry.rankSnapshot = data.rankSnapshot;
return entry;
});
}
/**
* `tierPoints > threshold` . `teamCode`
* . Firestore count aggregation .
*/
export async function countUsersAboveTierPoints(
threshold: number,
teamCode?: TeamCode
): Promise<number> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
const agg = await query.where("tierPoints", ">", threshold).count().get();
return agg.data().count;
}
/**
* `tierPoints > 0` (= ) . `teamCode`
* . percentile .
*/
export async function countRankedUsers(teamCode?: TeamCode): Promise<number> {
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
const agg = await query.where("tierPoints", ">", 0).count().get();
return agg.data().count;
}
/** /**
* displayName으로 uid를 . `null`. * displayName으로 uid를 . `null`.
* fallback. * fallback.

View File

@ -4,6 +4,11 @@ import { rtdb } from "../firebase.js";
import { setDay } from "../repositories/voteHistoryRepository.js"; import { setDay } from "../repositories/voteHistoryRepository.js";
import { invalidateStats } from "../services/statsService.js"; import { invalidateStats } from "../services/statsService.js";
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js"; import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js";
import {
precomputeScoreboardCache,
snapshotRankForUser,
} from "../services/rankSnapshotService.js";
import { todayKst } from "../types/dateString.js";
import { getGame } from "../repositories/gameRepository.js"; import { getGame } from "../repositories/gameRepository.js";
import { deleteUserVoteGame } from "../repositories/voteRepository.js"; import { deleteUserVoteGame } from "../repositories/voteRepository.js";
import { processGameEndWithGame } from "../services/gameResultService.js"; import { processGameEndWithGame } from "../services/gameResultService.js";
@ -114,6 +119,12 @@ export const dailyArchive = onSchedule(
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
await setDay(uid, date, { data }); await setDay(uid, date, { data });
await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
// 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함.
try {
await snapshotRankForUser(uid, date);
} catch (err) {
logger.error(`snapshotRank failed uid=${uid} date=${date}`, err);
}
try { try {
await judgeDay(uid, date, { data }); await judgeDay(uid, date, { data });
} catch (err) { } catch (err) {
@ -135,6 +146,12 @@ export const dailyArchive = onSchedule(
} }
} }
try {
await precomputeScoreboardCache(todayKst());
} catch (err) {
logger.error("precomputeScoreboardCache failed", err);
}
logger.info(`dailyArchive done: ${archived} users archived for ${date}`); logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
} }
); );

View File

@ -0,0 +1,148 @@
import { logger } from "firebase-functions";
import { firestore } from "../firebase.js";
import {
countRankedUsers,
countUsersAboveTierPoints,
getUser,
listTopByTierPoints,
type ScoreboardUserEntry,
} from "../repositories/userRepository.js";
import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository.js";
import { computePercentile, deltaFor } from "./scoreboardHelpers.js";
import type { DateString } from "../types/dateString.js";
import { TeamCode, type RankSnapshot } from "../types/panit.js";
import type {
ScoreboardEntry,
ScoreboardScopeCache,
} from "../types/scoreboard.js";
/**
* 1 `tierPoints` rank를 count aggregation으로
* `rankSnapshot` . `dailyArchive` `judgeDay` ****
* "직전 상태의 rank" .
*
* `tierPoints === 0` .
*/
export async function snapshotRankForUser(
uid: string,
date: DateString
): Promise<void> {
const user = await getUser(uid);
if (!user) return;
const tierPoints = user.tierPoints ?? 0;
if (tierPoints <= 0) return;
const overallAbove = await countUsersAboveTierPoints(tierPoints);
const snapshot: RankSnapshot = {
date,
overall: overallAbove + 1,
};
if (user.favoriteTeamCode) {
const teamAbove = await countUsersAboveTierPoints(
tierPoints,
user.favoriteTeamCode
);
snapshot.team = teamAbove + 1;
snapshot.teamCode = user.favoriteTeamCode;
}
await firestore
.collection("users")
.doc(uid)
.set({ rankSnapshot: snapshot }, { merge: true });
}
/**
* rank . .
*/
export async function snapshotRanksForUsers(
uids: string[],
date: DateString
): Promise<void> {
for (const uid of uids) {
try {
await snapshotRankForUser(uid, date);
} catch (err) {
logger.error(`snapshotRank failed for uid=${uid}`, err);
}
}
}
/** 동점자 동일 rank ("above count + 1"). 상위가 1위임을 가정한 in-memory 계산. */
function assignRanks(entries: ScoreboardUserEntry[]): number[] {
const ranks: number[] = [];
for (let i = 0; i < entries.length; i++) {
if (i === 0) {
ranks.push(1);
continue;
}
if (entries[i].tierPoints === entries[i - 1].tierPoints) {
ranks.push(ranks[i - 1]);
} else {
ranks.push(i + 1);
}
}
return ranks;
}
function enrichTop(
entries: ScoreboardUserEntry[],
totalCount: number,
scope: Scope
): ScoreboardEntry[] {
const ranks = assignRanks(entries);
return entries.map((e, i) => {
const rank = ranks[i];
const entry: ScoreboardEntry = {
uid: e.uid,
displayName: e.displayName,
tierPoints: e.tierPoints,
rank,
rankDelta: deltaFor(e.rankSnapshot, rank, scope),
percentile: computePercentile(rank, totalCount),
};
if (e.photoUrl) entry.photoUrl = e.photoUrl;
if (e.favoriteTeamCode) entry.favoriteTeamCode = e.favoriteTeamCode;
return entry;
});
}
async function buildScopeCache(scope: Scope): Promise<ScoreboardScopeCache> {
const teamCode = scope.kind === "team" ? scope.teamCode : undefined;
const [top, totalCount] = await Promise.all([
listTopByTierPoints(10, teamCode),
countRankedUsers(teamCode),
]);
return {
top: enrichTop(top, totalCount, scope),
totalCount,
generatedAt: Date.now(),
};
}
/**
* 호출: overall + 10 top 10 / totalCount를 RTDB에 .
* `snapshotRanksForUsers` rankDelta가 .
*/
export async function precomputeScoreboardCache(
date: DateString
): Promise<void> {
const scopes: Scope[] = [
{ kind: "overall" },
...Object.values(TeamCode).map(
(teamCode) => ({ kind: "team" as const, teamCode })
),
];
for (const scope of scopes) {
try {
const doc = await buildScopeCache(scope);
await writeScope(date, scope, doc);
} catch (err) {
const label =
scope.kind === "overall" ? "overall" : `team/${scope.teamCode}`;
logger.error(`precomputeScoreboardCache failed scope=${label}`, err);
}
}
}

View File

@ -0,0 +1,20 @@
import type { Scope } from "../repositories/scoreboardCacheRepository.js";
import type { RankSnapshot } from "../types/panit.js";
export function computePercentile(rank: number, totalCount: number): number {
if (totalCount <= 0) return 0;
return Math.round((1 - (rank - 1) / totalCount) * 100);
}
export function deltaFor(
snapshot: RankSnapshot | undefined,
currentRank: number,
scope: Scope
): number | null {
if (!snapshot) return null;
if (scope.kind === "overall") return snapshot.overall - currentRank;
if (snapshot.teamCode !== scope.teamCode || snapshot.team === undefined) {
return null;
}
return snapshot.team - currentRank;
}

View File

@ -0,0 +1,100 @@
import { MemCache } from "../lib/memCache.js";
import {
countUsersAboveTierPoints,
getUser,
} from "../repositories/userRepository.js";
import {
readScope,
type Scope,
} from "../repositories/scoreboardCacheRepository.js";
import { HttpError } from "../middleware/errors.js";
import { computePercentile, deltaFor } from "./scoreboardHelpers.js";
import { todayKst } from "../types/dateString.js";
import { type TeamCode } from "../types/panit.js";
import type {
ScoreboardEntry,
ScoreboardResponse,
ScoreboardScopeCache,
ScoreboardType,
} from "../types/scoreboard.js";
const BURST_TTL_MS = 30_000;
const scopeCache = new MemCache<ScoreboardScopeCache>(BURST_TTL_MS);
const meRankCache = new MemCache<{ rank: number; tierPoints: number }>(
BURST_TTL_MS
);
/** 테스트 전용: 캐시를 전부 비운다. */
export function __resetScoreboardCaches(): void {
// @ts-expect-error -- 내부 Map 접근.
scopeCache.cache.clear();
// @ts-expect-error
scopeCache.inflight.clear();
// @ts-expect-error
meRankCache.cache.clear();
// @ts-expect-error
meRankCache.inflight.clear();
}
function scopeOf(type: ScoreboardType, teamCode: TeamCode | undefined): Scope {
if (type === "overall") return { kind: "overall" };
if (!teamCode) throw new HttpError(400, "no favorite team");
return { kind: "team", teamCode };
}
function scopeKey(scope: Scope): string {
return scope.kind === "overall" ? "overall" : `team:${scope.teamCode}`;
}
export async function getScoreboard(
uid: string,
type: ScoreboardType
): Promise<ScoreboardResponse> {
const user = await getUser(uid);
if (!user) throw new HttpError(404, `user not found: ${uid}`);
const teamCode = type === "team" ? user.favoriteTeamCode : undefined;
const scope = scopeOf(type, teamCode);
const date = todayKst();
const cacheKey = `${date}:${scopeKey(scope)}`;
const cached = await scopeCache.getOrFetch(cacheKey, async () => {
const doc = await readScope(date, scope);
if (!doc) throw new HttpError(503, "scoreboard not ready");
return doc;
});
const myPoints = user.tierPoints ?? 0;
let me: ScoreboardEntry | null = null;
if (myPoints > 0) {
const { rank } = await meRankCache.getOrFetch(
`${cacheKey}:${uid}`,
async () => {
const above = await countUsersAboveTierPoints(myPoints, teamCode);
return { rank: above + 1, tierPoints: myPoints };
}
);
const entry: ScoreboardEntry = {
uid,
displayName: user.displayName,
tierPoints: myPoints,
rank,
rankDelta: deltaFor(user.rankSnapshot, rank, scope),
percentile: computePercentile(rank, cached.totalCount),
};
if (user.photoUrl) entry.photoUrl = user.photoUrl;
if (user.favoriteTeamCode) entry.favoriteTeamCode = user.favoriteTeamCode;
me = entry;
}
const response: ScoreboardResponse = {
type,
totalCount: cached.totalCount,
top: cached.top,
me,
};
if (teamCode) response.teamCode = teamCode;
return response;
}

View File

@ -46,6 +46,18 @@ export interface User {
tickets?: TicketMap; tickets?: TicketMap;
lastJudgedDate?: DateString; lastJudgedDate?: DateString;
lastWeeklyMasterTuesday?: DateString; lastWeeklyMasterTuesday?: DateString;
rankSnapshot?: RankSnapshot;
}
export interface RankSnapshot {
/** 스냅샷 생성 날짜 (KST). */
date: DateString;
/** 전체 스코어보드에서의 rank (동점자 동일 rank). */
overall: number;
/** 해당 팀 내 rank. 스냅샷 당시 `teamCode`와 함께만 유효. */
team?: number;
/** 스냅샷 생성 시점의 `favoriteTeamCode`. 이후 팀 변경 시 team delta 무효화용. */
teamCode?: TeamCode;
} }
export interface Game { export interface Game {

30
src/types/scoreboard.ts Normal file
View File

@ -0,0 +1,30 @@
import type { TeamCode } from "./panit.js";
export interface ScoreboardEntry {
uid: string;
displayName: string;
photoUrl?: string;
tierPoints: number;
favoriteTeamCode?: TeamCode;
rank: number;
/** 이전 rankSnapshot 대비 변화량. + 상승, 하락, null = 스냅샷 없음/팀 변경. */
rankDelta: number | null;
/** 0~100, 상위일수록 큼. */
percentile: number;
}
export interface ScoreboardScopeCache {
top: ScoreboardEntry[];
totalCount: number;
generatedAt: number;
}
export type ScoreboardType = "team" | "overall";
export interface ScoreboardResponse {
type: ScoreboardType;
teamCode?: TeamCode;
totalCount: number;
top: ScoreboardEntry[];
me: ScoreboardEntry | null;
}

View File

@ -0,0 +1,281 @@
import { beforeEach, describe, expect, it } from "vitest";
import { firestore, rtdb } from "../../src/firebase.js";
import {
__resetScoreboardCaches,
getScoreboard,
} from "../../src/services/scoreboardService.js";
import {
precomputeScoreboardCache,
snapshotRankForUser,
} from "../../src/services/rankSnapshotService.js";
import { todayKst } from "../../src/types/dateString.js";
import { TeamCode, type RankSnapshot } from "../../src/types/panit.js";
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",
};
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", 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", 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", 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", 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",
tierPoints: 200, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("b").set({
displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 100, favoriteTeamCode: TeamCode.LG,
});
await firestore.collection("users").doc("c").set({
displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual",
tierPoints: 150, favoriteTeamCode: TeamCode.KT,
});
await snapshotRankForUser("b", "2026-04-22");
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",
tierPoints: 0,
});
await snapshotRankForUser("zero", "2026-04-22");
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",
tierPoints: 50,
});
await snapshotRankForUser("solo", "2026-04-22");
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();
});
});