import { beforeEach, describe, expect, it } from "vitest"; import { firestore, rtdb } from "../../src/firebase"; import { __resetScoreboardCaches, getScoreboard, } from "../../src/services/scoreboardService"; import { precomputeScoreboardCache, snapshotRankForUser, } from "../../src/services/rankSnapshotService"; import { todayKst } 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 { 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(); }); });