mmday-firebase/tests/services/seasonService.test.ts
윤정민 477fcabc78 Add season records endpoint
- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환
- SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지
- 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
2026-07-23 14:55:13 +09:00

208 lines
6.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { FieldValue, Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase";
import {
getSeasonRecords,
maybeSettleSeason,
} from "../../src/services/seasonService";
import type { DateString } from "../../src/types/dateString";
import type { SeasonHistoryDoc } from "../../src/types/season";
import type { User } from "../../src/types/panit";
const END: DateString = "2026-10-30" as DateString;
const BEFORE_END: DateString = "2026-10-30" as DateString;
const AFTER_END: DateString = "2026-10-31" as DateString;
async function seedSeasonConfig(
patch: Record<string, unknown> = {}
): Promise<void> {
await firestore.collection("config").doc("season").set({
id: "2026",
startDate: "2026-03-28",
endDate: END,
...patch,
});
}
async function seedUser(
uid: string,
patch: Partial<User> & Record<string, unknown> = {}
): Promise<void> {
await firestore.collection("users").doc(uid).set({
displayName: uid,
email: `${uid}@e.com`,
provider: "google",
knowledgeLevel: "beginner",
createdAt: Timestamp.now(),
active: true,
...patch,
});
}
async function readUser(uid: string): Promise<Partial<User>> {
const snap = await firestore.collection("users").doc(uid).get();
return (snap.data() ?? {}) as Partial<User>;
}
async function readHistory(
uid: string,
seasonId = "2026"
): Promise<Partial<SeasonHistoryDoc> | null> {
const snap = await firestore
.collection("users")
.doc(uid)
.collection("seasonHistory")
.doc(seasonId)
.get();
return snap.exists ? (snap.data() as Partial<SeasonHistoryDoc>) : null;
}
describe("seasonService.maybeSettleSeason", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
await firestore.recursiveDelete(
firestore.collection("config").doc("season")
);
});
it("config/season이 없으면 no-op", async () => {
await seedUser("a", { tierPoints: 600 });
const result = await maybeSettleSeason(AFTER_END);
expect(result.settled).toBe(false);
expect((await readUser("a")).tierPoints).toBe(600);
});
it("endDate 당일까지는 no-op (마지막 날 판정 완료 후 다음 날 정산)", async () => {
await seedSeasonConfig();
await seedUser("a", { tierPoints: 600 });
const result = await maybeSettleSeason(BEFORE_END);
expect(result.settled).toBe(false);
expect((await readUser("a")).tierPoints).toBe(600);
expect(await readHistory("a")).toBeNull();
});
it("endDate 다음 날 정산: 아카이브·rank(동점 동일)·리셋·settledAt", async () => {
await seedSeasonConfig();
await seedUser("gold", {
tierPoints: 600,
rankSnapshot: { date: END, overall: 1 },
});
await seedUser("tie1", { tierPoints: 200 });
await seedUser("tie2", { tierPoints: 200 });
// 비활성 유저는 랭킹 모집단이 아니므로 정산·리셋 대상도 아니다.
await seedUser("ghost", { tierPoints: 999, active: false });
const result = await maybeSettleSeason(AFTER_END);
expect(result).toEqual({ settled: true, users: 3 });
const gold = await readHistory("gold");
expect(gold).toMatchObject({
seasonId: "2026",
tierPoints: 600,
tier: "ST", // 500 <= 600 < 1200
rank: 1,
totalRanked: 3,
});
expect((await readHistory("tie1"))?.rank).toBe(2);
expect((await readHistory("tie2"))?.rank).toBe(2);
expect((await readHistory("tie1"))?.tier).toBe("PR"); // 150 <= 200 < 500
const goldUser = await readUser("gold");
expect(goldUser.tierPoints).toBe(0);
expect(goldUser.rankSnapshot).toBeUndefined();
expect((await readUser("tie1")).tierPoints).toBe(0);
expect((await readUser("ghost")).tierPoints).toBe(999);
expect(await readHistory("ghost")).toBeNull();
const config = await firestore.collection("config").doc("season").get();
expect(config.data()?.settledAt).toBeInstanceOf(Timestamp);
});
it("settledAt이 있으면 재실행해도 no-op (멱등)", async () => {
await seedSeasonConfig({ settledAt: Timestamp.now() });
await seedUser("a", { tierPoints: 600 });
const result = await maybeSettleSeason(AFTER_END);
expect(result.settled).toBe(false);
expect((await readUser("a")).tierPoints).toBe(600);
expect(await readHistory("a")).toBeNull();
});
it("부분 실패 재실행: 기정산 유저 점수를 순위에 합류시켜 rank가 보존된다", async () => {
await seedSeasonConfig();
// 첫 실행에서 1위(600pt)만 정산된 뒤 실패했다고 가정 —
// 이미 아카이브 + 리셋된 상태이고 settledAt은 아직 없다.
await seedUser("done", { tierPoints: 0 });
await firestore
.collection("users")
.doc("done")
.collection("seasonHistory")
.doc("2026")
.set({
seasonId: "2026",
tierPoints: 600,
tier: "ST",
rank: 1,
totalRanked: 3,
settledAt: FieldValue.serverTimestamp(),
});
await seedUser("b", { tierPoints: 300 });
await seedUser("c", { tierPoints: 100 });
const result = await maybeSettleSeason(AFTER_END);
expect(result).toEqual({ settled: true, users: 2 });
// done(600)이 순위표에 합류했으므로 b는 2위, c는 3위가 된다.
expect(await readHistory("b")).toMatchObject({
tierPoints: 300,
rank: 2,
totalRanked: 3,
});
expect(await readHistory("c")).toMatchObject({
tierPoints: 100,
rank: 3,
totalRanked: 3,
});
// 기정산 유저의 아카이브는 덮어쓰지 않는다.
expect((await readHistory("done"))?.rank).toBe(1);
expect((await readUser("b")).tierPoints).toBe(0);
});
it("getSeasonRecords: 정산 전 빈 목록, 정산 후 최근 시즌부터 DTO 반환", async () => {
await seedUser("a");
expect(await getSeasonRecords("a")).toEqual([]);
const history = firestore
.collection("users")
.doc("a")
.collection("seasonHistory");
await history.doc("2025").set({
seasonId: "2025",
tierPoints: 300,
tier: "PR",
rank: 5,
totalRanked: 10,
settledAt: Timestamp.now(),
});
await history.doc("2026").set({
seasonId: "2026",
tierPoints: 900,
tier: "ST",
rank: 2,
totalRanked: 12,
settledAt: Timestamp.now(),
});
const records = await getSeasonRecords("a");
expect(records.map((r) => r.seasonId)).toEqual(["2026", "2025"]);
expect(records[0]).toMatchObject({
tierPoints: 900,
tier: "ST",
rank: 2,
totalRanked: 12,
});
// Timestamp가 그대로 유출되지 않고 ISO 문자열로 변환된다.
expect(typeof records[0].settledAt).toBe("string");
});
});