- 회원 탈퇴 시 즉시 삭제 대신 `active: false` 및 `deactivatedAt`을 기록하여 30일간 데이터를 유예하는 소프트 삭제 방식을 도입했습니다. - 경기 취소 시 투표를 삭제하는 대신 `cancelled: true`로 마킹하여 참여 흔적을 보존하고, 통계 집계에서만 제외하도록 로직을 변경했습니다. - 유예 기간이 지난 비활성 계정을 영구적으로 파기하는 `accountPurge` 스케줄러를 추가했습니다.
284 lines
10 KiB
TypeScript
284 lines
10 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",
|
|
// 신규 가입 기본값과 동일 — 랭킹 쿼리의 active 필터를 통과해야 한다.
|
|
active: true,
|
|
};
|
|
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",
|
|
active: true, tierPoints: 200, favoriteTeamCode: TeamCode.LG,
|
|
});
|
|
await firestore.collection("users").doc("b").set({
|
|
displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual",
|
|
active: true, tierPoints: 100, favoriteTeamCode: TeamCode.LG,
|
|
});
|
|
await firestore.collection("users").doc("c").set({
|
|
displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual",
|
|
active: true, 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",
|
|
active: true, 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",
|
|
active: true, 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();
|
|
});
|
|
});
|