- 전체 및 팀별 스코어보드 기능을 구현하고, 순위 변동(delta)을 시각화하기 위한 스냅샷 시스템을 도입했습니다. - `dailyArchive` 과정에서 사용자의 현재 순위를 `rankSnapshot`으로 기록하여, 다음 판정 시점의 순위 변화량을 정확히 계산합니다. - 상위권 데이터는 RTDB에 사전 계산(precompute)하여 저장함으로써 대규모 조회 요청에 대응하고, `MemCache`를 통해 API 응답 성능을 최적화했습니다. - Firestore 복합 색인과 Count Aggregation을 활용하여 동점자 처리가 포함된 랭킹 및 상위 퍼센타일 산출 로직을 구현했습니다. - 관련 서비스 레이어, 저장소 함수, API 핸들러 및 단위 테스트 코드를 추가했습니다.
282 lines
9.7 KiB
TypeScript
282 lines
9.7 KiB
TypeScript
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();
|
|
});
|
|
});
|