Add season-end settlement and tierPoints reset
- 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 인덱스 추가, 정산 시나리오 테스트 신규 작성
This commit is contained in:
parent
a38d7ade28
commit
cf5f10af64
@ -124,6 +124,13 @@
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
{
|
||||
"collectionGroup": "seasonHistory",
|
||||
"fieldPath": "seasonId",
|
||||
"indexes": [
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "products",
|
||||
"fieldPath": "mainImages",
|
||||
|
||||
@ -22,6 +22,12 @@ service cloud.firestore {
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 시즌 종료 시 서버가 기록하는 최종 성적 아카이브 — 본인 조회 전용.
|
||||
match /seasonHistory/{seasonId} {
|
||||
allow read: if request.auth != null && request.auth.uid == uid;
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
match /attendance/{month} {
|
||||
allow read: if request.auth != null && request.auth.uid == uid;
|
||||
allow write: if false;
|
||||
|
||||
85
src/repositories/seasonRepository.ts
Normal file
85
src/repositories/seasonRepository.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { isDateString } from "../types/dateString";
|
||||
import type { SeasonConfig, SeasonHistoryDoc } from "../types/season";
|
||||
|
||||
const CONFIG_COLLECTION = "config";
|
||||
const CONFIG_DOC = "season";
|
||||
|
||||
/**
|
||||
* `config/season`을 읽는다. 문서가 없거나 필수 필드가 온전치 않으면
|
||||
* 시즌제 비활성으로 취급해 `null`을 반환한다.
|
||||
*/
|
||||
export async function getSeasonConfig(): Promise<SeasonConfig | null> {
|
||||
const snap = await firestore
|
||||
.collection(CONFIG_COLLECTION)
|
||||
.doc(CONFIG_DOC)
|
||||
.get();
|
||||
if (!snap.exists) return null;
|
||||
const data = snap.data() ?? {};
|
||||
if (typeof data.id !== "string" || data.id.length === 0) return null;
|
||||
if (!isDateString(data.startDate) || !isDateString(data.endDate)) return null;
|
||||
const config: SeasonConfig = {
|
||||
id: data.id,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
};
|
||||
if (data.settledAt instanceof Timestamp) config.settledAt = data.settledAt;
|
||||
return config;
|
||||
}
|
||||
|
||||
/** 정산 완료 마커 기록 — 이후 `maybeSettleSeason`은 no-op이 된다. */
|
||||
export async function markSeasonSettled(): Promise<void> {
|
||||
await firestore
|
||||
.collection(CONFIG_COLLECTION)
|
||||
.doc(CONFIG_DOC)
|
||||
.set({ settledAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 시즌의 `seasonHistory`가 이미 기록된 유저와 그 최종 점수 목록.
|
||||
* 정산이 중간에 실패했던 경우, 재실행에서 이들의 점수를 순위 산정에
|
||||
* 다시 합류시키기 위한 조회다(collection group — seasonId 단일 필드 인덱스 필요).
|
||||
*/
|
||||
export async function listSeasonHistoryEntries(
|
||||
seasonId: string
|
||||
): Promise<Array<{ uid: string; tierPoints: number }>> {
|
||||
const snap = await firestore
|
||||
.collectionGroup("seasonHistory")
|
||||
.where("seasonId", "==", seasonId)
|
||||
.get();
|
||||
return snap.docs
|
||||
.map((d) => ({
|
||||
uid: d.ref.parent.parent?.id ?? "",
|
||||
tierPoints: (d.data() as Partial<SeasonHistoryDoc>).tierPoints ?? 0,
|
||||
}))
|
||||
.filter((e) => e.uid.length > 0);
|
||||
}
|
||||
|
||||
/** 배치 한도(500 write)의 여유분 — 유저당 2 write(아카이브 + 리셋). */
|
||||
const SETTLE_CHUNK = 200;
|
||||
|
||||
/**
|
||||
* 유저별 시즌 정산 쓰기: `seasonHistory/{seasonId}` 아카이브와
|
||||
* `tierPoints` 리셋(+`rankSnapshot` 제거)을 같은 배치에 묶는다.
|
||||
* 중간 실패 시에도 유저 단위로는 "정산 완료" 아니면 "점수 유지"만 존재한다.
|
||||
*/
|
||||
export async function settleUsers(
|
||||
entries: Array<{ uid: string; history: Omit<SeasonHistoryDoc, "settledAt"> }>
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < entries.length; i += SETTLE_CHUNK) {
|
||||
const batch = firestore.batch();
|
||||
for (const { uid, history } of entries.slice(i, i + SETTLE_CHUNK)) {
|
||||
const userRef = firestore.collection("users").doc(uid);
|
||||
batch.set(userRef.collection("seasonHistory").doc(history.seasonId), {
|
||||
...history,
|
||||
settledAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
batch.update(userRef, {
|
||||
tierPoints: 0,
|
||||
rankSnapshot: FieldValue.delete(),
|
||||
});
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
@ -123,6 +123,39 @@ export async function listTopByTierPoints(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 대상(active && tierPoints > 0) 전원을 tierPoints 내림차순으로 반환한다.
|
||||
* "랭킹 대상" 정의를 스코어보드(`listTopByTierPoints`/`countRankedUsers`)와
|
||||
* 공유한다 — 시즌 정산도 같은 모집단을 리셋해야 순위와 리셋 범위가 일치한다.
|
||||
* 시즌 정산 전용: 페이지 단위로 나눠 읽되 결과는 전량 메모리에 올린다.
|
||||
*/
|
||||
export async function listAllRankedUsers(): Promise<
|
||||
Array<{ uid: string; tierPoints: number }>
|
||||
> {
|
||||
const PAGE = 500;
|
||||
const results: Array<{ uid: string; tierPoints: number }> = [];
|
||||
let last: FirebaseFirestore.QueryDocumentSnapshot | undefined;
|
||||
for (;;) {
|
||||
let query = firestore
|
||||
.collection(COLLECTION)
|
||||
.where("active", "==", true)
|
||||
.where("tierPoints", ">", 0)
|
||||
.orderBy("tierPoints", "desc")
|
||||
.select("tierPoints")
|
||||
.limit(PAGE);
|
||||
if (last) query = query.startAfter(last);
|
||||
const snap = await query.get();
|
||||
for (const d of snap.docs) {
|
||||
results.push({
|
||||
uid: d.id,
|
||||
tierPoints: (d.data() as Partial<User>).tierPoints ?? 0,
|
||||
});
|
||||
}
|
||||
if (snap.docs.length < PAGE) return results;
|
||||
last = snap.docs[snap.docs.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `tierPoints > threshold`인 유저 수를 반환한다. `teamCode` 지정 시 해당
|
||||
* 팀을 응원하는 유저로 한정한다. Firestore count aggregation 사용.
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
precomputeScoreboardCache,
|
||||
computeRankSnapshot,
|
||||
} from "../services/rankSnapshotService";
|
||||
import { maybeSettleSeason } from "../services/seasonService";
|
||||
import { todayKst } from "../types/dateString";
|
||||
import { getGame, createGameDayCache } from "../repositories/gameRepository";
|
||||
import { processGameEndWithGame } from "../services/gameResultService";
|
||||
@ -164,6 +165,14 @@ export async function runDailyArchive(
|
||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||
return { date, archived, judgedUids };
|
||||
} finally {
|
||||
// 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤
|
||||
// 정산·리셋하고, 이어지는 캐시 재계산이 리셋된(새 시즌) 보드를 만든다.
|
||||
// 정산 실패가 캐시 재계산을 막지 않도록 각각 독립적으로 감싼다.
|
||||
try {
|
||||
await maybeSettleSeason(todayKst());
|
||||
} catch (err) {
|
||||
logger.error("maybeSettleSeason failed", err);
|
||||
}
|
||||
try {
|
||||
await precomputeScoreboardCache(todayKst());
|
||||
} catch (err) {
|
||||
|
||||
83
src/services/seasonService.ts
Normal file
83
src/services/seasonService.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { logger } from "firebase-functions";
|
||||
import { tierOf } from "../constants/tiers";
|
||||
import {
|
||||
getSeasonConfig,
|
||||
listSeasonHistoryEntries,
|
||||
markSeasonSettled,
|
||||
settleUsers,
|
||||
} from "../repositories/seasonRepository";
|
||||
import { listAllRankedUsers } from "../repositories/userRepository";
|
||||
import { invalidateStats } from "./statsService";
|
||||
import type { DateString } from "../types/dateString";
|
||||
import type { SeasonHistoryDoc } from "../types/season";
|
||||
|
||||
/**
|
||||
* 시즌 종료 정산. `dailyArchive`가 매일 호출하지만 실제 실행 조건은
|
||||
* "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다.
|
||||
*
|
||||
* 동작: 랭킹 대상(active && tierPoints > 0) 전원의 최종 성적을
|
||||
* `users/{uid}/seasonHistory/{seasonId}`에 아카이브한 뒤 `tierPoints`를 0으로
|
||||
* 리셋하고 `rankSnapshot`을 제거한다. 스트릭은 시즌과 무관하므로 유지한다
|
||||
* (이월 이득은 streakBonus 상한이 제한 — constants/judgment.ts 참고).
|
||||
*
|
||||
* 재실행 안전성: 유저 단위(아카이브+리셋)가 같은 배치로 묶이므로 중간 실패 시
|
||||
* "정산 완료(0pt + history)"와 "미정산(점수 유지)" 유저만 존재한다. 재실행에서는
|
||||
* 이미 정산된 유저의 최종 점수를 seasonHistory에서 되읽어 순위 산정에 합류시켜
|
||||
* 미정산 유저도 원래와 같은 rank/totalRanked를 받는다. `settledAt` 마커는
|
||||
* 전원 완료 후에만 기록한다.
|
||||
*/
|
||||
export async function maybeSettleSeason(
|
||||
today: DateString
|
||||
): Promise<{ settled: boolean; users: number }> {
|
||||
const config = await getSeasonConfig();
|
||||
if (!config || config.settledAt) return { settled: false, users: 0 };
|
||||
// DateString(YYYY-MM-DD)은 사전순 비교가 곧 날짜 비교다.
|
||||
if (today <= config.endDate) return { settled: false, users: 0 };
|
||||
|
||||
const [live, alreadySettled] = await Promise.all([
|
||||
listAllRankedUsers(),
|
||||
listSeasonHistoryEntries(config.id),
|
||||
]);
|
||||
const settledUids = new Set(alreadySettled.map((e) => e.uid));
|
||||
const standings = [
|
||||
...live.filter((e) => !settledUids.has(e.uid)),
|
||||
...alreadySettled,
|
||||
].sort((a, b) => b.tierPoints - a.tierPoints);
|
||||
|
||||
const totalRanked = standings.length;
|
||||
const entries: Array<{
|
||||
uid: string;
|
||||
history: Omit<SeasonHistoryDoc, "settledAt">;
|
||||
}> = [];
|
||||
let rank = 0;
|
||||
let prevPoints = -1;
|
||||
standings.forEach((e, i) => {
|
||||
if (e.tierPoints !== prevPoints) {
|
||||
rank = i + 1;
|
||||
prevPoints = e.tierPoints;
|
||||
}
|
||||
// 이미 아카이브된 유저는 첫 실행 때 같은 순위표로 rank가 확정되어 있다.
|
||||
if (settledUids.has(e.uid)) return;
|
||||
entries.push({
|
||||
uid: e.uid,
|
||||
history: {
|
||||
seasonId: config.id,
|
||||
tierPoints: e.tierPoints,
|
||||
tier: tierOf(e.tierPoints),
|
||||
rank,
|
||||
totalRanked,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await settleUsers(entries);
|
||||
await markSeasonSettled();
|
||||
// 리셋된 tier/tierPoints가 /stats 캐시에 남지 않도록 무효화(실패 무시).
|
||||
await Promise.all(
|
||||
entries.map((e) => invalidateStats(e.uid).catch(() => undefined))
|
||||
);
|
||||
logger.info(
|
||||
`season ${config.id} settled: ${entries.length} users archived+reset (totalRanked=${totalRanked})`
|
||||
);
|
||||
return { settled: true, users: entries.length };
|
||||
}
|
||||
39
src/types/season.ts
Normal file
39
src/types/season.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { Timestamp } from "firebase-admin/firestore";
|
||||
import type { DateString } from "./dateString";
|
||||
import type { TierName } from "./panit";
|
||||
|
||||
/**
|
||||
* `config/season` — 현재 KBO 시즌의 경계 설정.
|
||||
*
|
||||
* 문서가 없으면 시즌제 비활성(정산·리셋이 일어나지 않음). 새 시즌 시작 시
|
||||
* 운영자가 새 `id`/기간으로 문서를 덮어쓴다(이때 `settledAt`도 함께 제거).
|
||||
*/
|
||||
export interface SeasonConfig {
|
||||
/** 시즌 식별자 (예: "2026"). `seasonHistory` 문서 id로도 쓰인다. */
|
||||
id: string;
|
||||
/** 시즌 시작일(KST, 개막일). 정산 트리거는 `endDate`만 보므로 참고용. */
|
||||
startDate: DateString;
|
||||
/** 시즌 종료일(KST, 한국시리즈 종료일). 이 날짜가 지난 뒤 최초 archive에서 정산된다. */
|
||||
endDate: DateString;
|
||||
/** 정산 완료 시각. 존재하면 이 시즌은 다시 정산하지 않는다(멱등 마커). */
|
||||
settledAt?: Timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* `users/{uid}/seasonHistory/{seasonId}` — 시즌 종료 시점의 최종 성적 아카이브.
|
||||
*
|
||||
* 정산(`maybeSettleSeason`) 시 서버만 기록한다. 이후 시즌 보상 지급·명예의 전당
|
||||
* 등재의 근거 데이터가 된다. 시즌 정산 보상은 현 단계에서 미구현(기획 보류).
|
||||
*/
|
||||
export interface SeasonHistoryDoc {
|
||||
seasonId: string;
|
||||
/** 시즌 최종 tierPoints (리셋 직전 값). */
|
||||
tierPoints: number;
|
||||
/** 최종 티어 코드. */
|
||||
tier: TierName;
|
||||
/** 전체 랭킹 — 동점자 동일 rank("above count + 1"), 스코어보드와 같은 규칙. */
|
||||
rank: number;
|
||||
/** 랭킹 대상(active && tierPoints > 0) 총 인원. */
|
||||
totalRanked: number;
|
||||
settledAt: Timestamp;
|
||||
}
|
||||
167
tests/services/seasonService.test.ts
Normal file
167
tests/services/seasonService.test.ts
Normal file
@ -0,0 +1,167 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user