- config/season 문서(id/startDate/endDate)로 시즌 경계 정의 — 문서가 없으면 시즌제 비활성(기존 동작 유지)
- endDate 다음 날 dailyArchive에서 maybeSettleSeason 1회 실행: 랭킹 대상 전원의 최종 성적(tierPoints·티어·동점 동일 rank)을 users/{uid}/seasonHistory/{seasonId}에 아카이브 후 tierPoints 0 리셋·rankSnapshot 제거
- 유저별 아카이브+리셋을 같은 배치로 묶고 settledAt 마커는 전원 완료 후 기록 — 부분 실패 재실행 시 기정산 유저 점수를 collectionGroup으로 되읽어 순위 보존(멱등)
- 스트릭은 시즌과 무관하게 유지(이월 이득은 streakBonus 상한이 제한)
- seasonHistory 본인 읽기 전용 rules와 seasonId collectionGroup 인덱스 추가, 정산 시나리오 테스트 신규 작성
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
import { firestore } from "../../src/firebase";
|
|
import { 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);
|
|
});
|
|
});
|