- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환 - SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지 - 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
208 lines
6.7 KiB
TypeScript
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");
|
|
});
|
|
});
|