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 = {} ): Promise { await firestore.collection("config").doc("season").set({ id: "2026", startDate: "2026-03-28", endDate: END, ...patch, }); } async function seedUser( uid: string, patch: Partial & Record = {} ): Promise { 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> { const snap = await firestore.collection("users").doc(uid).get(); return (snap.data() ?? {}) as Partial; } async function readHistory( uid: string, seasonId = "2026" ): Promise | null> { const snap = await firestore .collection("users") .doc(uid) .collection("seasonHistory") .doc(seasonId) .get(); return snap.exists ? (snap.data() as Partial) : 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"); }); });