mmday-firebase/tests/services/scoreboardService.test.ts
윤정민 75a35d965a Tidy up service test fixtures and type assertions.
- scoreboardService·statsService 테스트에서 날짜 리터럴에 DateString 타입 단언을 추가해 타입 안정성을 보정했습니다.
- chatToolService·gameSyncService 테스트의 사소한 픽스처/단언을 정리했습니다.
2026-06-23 10:46:49 +09:00

282 lines
9.8 KiB
TypeScript

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, 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",
};
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",
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" 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",
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",
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();
});
});