From b25e78dde8af71100376e4fee323acd7fe84326b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 23 Apr 2026 09:19:04 +0900 Subject: [PATCH] Implement scoreboard system with rank snapshots and delta tracking. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 및 팀별 스코어보드 기능을 구현하고, 순위 변동(delta)을 시각화하기 위한 스냅샷 시스템을 도입했습니다. - `dailyArchive` 과정에서 사용자의 현재 순위를 `rankSnapshot`으로 기록하여, 다음 판정 시점의 순위 변화량을 정확히 계산합니다. - 상위권 데이터는 RTDB에 사전 계산(precompute)하여 저장함으로써 대규모 조회 요청에 대응하고, `MemCache`를 통해 API 응답 성능을 최적화했습니다. - Firestore 복합 색인과 Count Aggregation을 활용하여 동점자 처리가 포함된 랭킹 및 상위 퍼센타일 산출 로직을 구현했습니다. - 관련 서비스 레이어, 저장소 함수, API 핸들러 및 단위 테스트 코드를 추가했습니다. --- firestore.indexes.json | 11 +- src/handlers/predictionHandlers.ts | 15 +- src/repositories/scoreboardCacheRepository.ts | 36 +++ src/repositories/userRepository.ts | 71 +++++ src/scheduled/dailyArchive.ts | 17 ++ src/services/rankSnapshotService.ts | 148 +++++++++ src/services/scoreboardHelpers.ts | 20 ++ src/services/scoreboardService.ts | 100 +++++++ src/types/panit.ts | 12 + src/types/scoreboard.ts | 30 ++ tests/services/scoreboardService.test.ts | 281 ++++++++++++++++++ 11 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 src/repositories/scoreboardCacheRepository.ts create mode 100644 src/services/rankSnapshotService.ts create mode 100644 src/services/scoreboardHelpers.ts create mode 100644 src/services/scoreboardService.ts create mode 100644 src/types/scoreboard.ts create mode 100644 tests/services/scoreboardService.test.ts diff --git a/firestore.indexes.json b/firestore.indexes.json index 0e6de7c..531658f 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -46,6 +46,15 @@ // ] // }, // ] - "indexes": [], + "indexes": [ + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "favoriteTeamCode", "order": "ASCENDING" }, + { "fieldPath": "tierPoints", "order": "DESCENDING" } + ] + } + ], "fieldOverrides": [] } \ No newline at end of file diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index 033fed4..016bc6b 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -1,6 +1,6 @@ import { onRequest } from "firebase-functions/https"; import { requireAuth } from "../middleware/auth.js"; -import { sendError } from "../middleware/errors.js"; +import { HttpError, sendError } from "../middleware/errors.js"; import { createPrediction, updatePrediction, @@ -8,6 +8,7 @@ import { getSummary, listGamesByDate, } from "../services/predictionService.js"; +import { getScoreboard } from "../services/scoreboardService.js"; export const prediction = onRequest(async (req, res) => { const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); @@ -21,6 +22,18 @@ export const prediction = onRequest(async (req, res) => { 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") { const gameId = String(req.query.gameId ?? ""); const result = await getSummary(gameId); diff --git a/src/repositories/scoreboardCacheRepository.ts b/src/repositories/scoreboardCacheRepository.ts new file mode 100644 index 0000000..4b6756c --- /dev/null +++ b/src/repositories/scoreboardCacheRepository.ts @@ -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 { + 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 { + await rtdb.ref(pathFor(date, scope)).set(doc); +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index e921847..0f409e6 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -4,6 +4,7 @@ import type { DailyJudgment, KnowledgeLevel, Provider, + RankSnapshot, TeamCode, User, } from "../types/panit"; @@ -31,6 +32,76 @@ export async function getUser(uid: string): Promise { 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 { + 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; + 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 { + 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 { + 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`. * 닉네임 유니크성 검증용 fallback. diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 96f5954..3d4dad5 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -4,6 +4,11 @@ import { rtdb } from "../firebase.js"; import { setDay } from "../repositories/voteHistoryRepository.js"; import { invalidateStats } from "../services/statsService.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 { deleteUserVoteGame } from "../repositories/voteRepository.js"; import { processGameEndWithGame } from "../services/gameResultService.js"; @@ -114,6 +119,12 @@ export const dailyArchive = onSchedule( // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. await setDay(uid, date, { data }); 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 { await judgeDay(uid, date, { data }); } 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}`); } ); diff --git a/src/services/rankSnapshotService.ts b/src/services/rankSnapshotService.ts new file mode 100644 index 0000000..90037f0 --- /dev/null +++ b/src/services/rankSnapshotService.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/services/scoreboardHelpers.ts b/src/services/scoreboardHelpers.ts new file mode 100644 index 0000000..63a92b3 --- /dev/null +++ b/src/services/scoreboardHelpers.ts @@ -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; +} diff --git a/src/services/scoreboardService.ts b/src/services/scoreboardService.ts new file mode 100644 index 0000000..dc1d8f2 --- /dev/null +++ b/src/services/scoreboardService.ts @@ -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(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 { + 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; +} diff --git a/src/types/panit.ts b/src/types/panit.ts index 390c012..b0b20b1 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -46,6 +46,18 @@ export interface User { tickets?: TicketMap; lastJudgedDate?: 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 { diff --git a/src/types/scoreboard.ts b/src/types/scoreboard.ts new file mode 100644 index 0000000..b67b96a --- /dev/null +++ b/src/types/scoreboard.ts @@ -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; +} diff --git a/tests/services/scoreboardService.test.ts b/tests/services/scoreboardService.test.ts new file mode 100644 index 0000000..8aa9671 --- /dev/null +++ b/tests/services/scoreboardService.test.ts @@ -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 { + const doc: Record = { + 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(); + }); +});