- dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다) - 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백 - user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다) - reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다) - settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다 - 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다 - stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다 - precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다) - 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일)
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { Timestamp } from "firebase-admin/firestore";
|
|
import { firestore, rtdb } from "../../src/firebase";
|
|
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
|
|
import { getStats } from "../../src/services/statsService";
|
|
import {
|
|
addDays,
|
|
todayKst,
|
|
type DateString,
|
|
} from "../../src/types/dateString";
|
|
import type {
|
|
Game,
|
|
GameStatus,
|
|
StatsResponse,
|
|
User,
|
|
} from "../../src/types/panit";
|
|
|
|
const uid = "stats-uid";
|
|
|
|
async function seedGames(
|
|
date: DateString,
|
|
specs: Array<{ status: GameStatus; winner?: string }>
|
|
): Promise<void> {
|
|
for (let i = 0; i < specs.length; i++) {
|
|
const spec = specs[i];
|
|
const gameId = `${date.replace(/-/g, "")}G${i}`;
|
|
const [y, m, d] = date.split("-").map(Number);
|
|
const doc: Game = {
|
|
time: Timestamp.fromDate(new Date(Date.UTC(y, m - 1, d, 9, 0))),
|
|
stadium: "잠실",
|
|
status: spec.status,
|
|
homeTeamCode: "LG",
|
|
awayTeamCode: "HT",
|
|
};
|
|
if (spec.winner) doc.winningTeamCode = spec.winner;
|
|
await firestore.collection("games").doc(gameId).set(doc);
|
|
invalidateAllGameDays();
|
|
}
|
|
}
|
|
|
|
async function seedUser(patch: Partial<User> = {}): Promise<void> {
|
|
await firestore
|
|
.collection("users")
|
|
.doc(uid)
|
|
.set(
|
|
{
|
|
displayName: "stats-user",
|
|
email: "s@e.com",
|
|
provider: "google",
|
|
knowledgeLevel: "beginner",
|
|
createdAt: Timestamp.now(),
|
|
...patch,
|
|
},
|
|
{ merge: true }
|
|
);
|
|
}
|
|
|
|
async function seedCache(
|
|
key: string,
|
|
partial: Partial<StatsResponse>
|
|
): Promise<void> {
|
|
await rtdb.ref(`/cache/stats/${uid}/${key}`).set(partial);
|
|
}
|
|
|
|
describe("statsService.getStats — 결석 lazy 보정", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await firestore.recursiveDelete(firestore.collection("games"));
|
|
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
|
|
invalidateAllGameDays();
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await rtdb.ref(`/userVotes/${uid}`).remove();
|
|
});
|
|
|
|
it("lastJudgedDate가 이틀 이상 이전이고 gap에 실제 경기일이 있으면 streak 0", async () => {
|
|
const today = todayKst();
|
|
const threeDaysAgo = addDays(today, -3);
|
|
const twoDaysAgo = addDays(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 7,
|
|
lastJudgedDate: threeDaysAgo,
|
|
tierPoints: 100,
|
|
});
|
|
// gap에 실제 경기일을 둔다 → 결석으로 판정되어야 함.
|
|
await seedGames(twoDaysAgo, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
expect(stats.highestStreak).toBe(7); // 최고 streak은 그대로
|
|
expect(stats.forDate).toBe(today);
|
|
});
|
|
|
|
it("gap에 실제 경기일이 없으면(전부 휴장) streak 유지", async () => {
|
|
const today = todayKst();
|
|
const threeDaysAgo = addDays(today, -3);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 7,
|
|
lastJudgedDate: threeDaysAgo,
|
|
});
|
|
// games 컬렉션 비움 — 모든 gap 날이 0건이므로 skip 처리, 결석 아님.
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("lastJudgedDate가 어제(D-1)면 streak 유지", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDays(today, -1);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: yesterday,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("lastJudgedDate가 D-2여도 어제분 userVotes가 남아 있으면 archive 대기로 보고 streak 보호", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDays(today, -1);
|
|
const twoDaysAgo = addDays(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: twoDaysAgo,
|
|
});
|
|
// 어제 투표는 했으나 아직 dailyArchive가 안 돈 상황을 시뮬레이트.
|
|
await rtdb
|
|
.ref(`/userVotes/${uid}/${yesterday}/g1`)
|
|
.set({ team: "LG" });
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("어제 경기가 있었는데 userVotes 비어 있으면(진짜 결석) streak 0", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDays(today, -1);
|
|
const twoDaysAgo = addDays(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: twoDaysAgo,
|
|
});
|
|
// 어제 실제 경기가 있었음 + userVotes 없음 = 진짜 결석.
|
|
await seedGames(yesterday, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
});
|
|
|
|
it("lastJudgedDate가 없는 신규 유저는 streak 0", async () => {
|
|
await seedUser({
|
|
tierPoints: 0,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("statsService.getStats — 캐시 forDate 검증", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await rtdb.ref(`/userVotes/${uid}`).remove();
|
|
});
|
|
|
|
it("캐시 forDate가 오늘이면 그대로 반환한다", async () => {
|
|
const today = todayKst();
|
|
await seedUser({ currentStreak: 99, lastJudgedDate: today });
|
|
// 일부러 streakDays를 비현실적인 값으로 캐시에 저장 → 캐시 hit이면 그대로 노출
|
|
await seedCache("current", {
|
|
streakDays: 12345,
|
|
highestStreak: 12345,
|
|
weeklyResults: [null, null, null, null, null, null, null],
|
|
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
|
|
totalPredictions: 0,
|
|
totalCorrect: 0,
|
|
weeklyPredictions: 0,
|
|
tier: "BW",
|
|
tierPoints: 0,
|
|
updatedAt: Date.now(),
|
|
forDate: today,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(12345);
|
|
});
|
|
|
|
it("캐시 forDate가 어제면 무효화하고 재계산한다", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDays(today, -1);
|
|
await seedUser({ currentStreak: 3, lastJudgedDate: yesterday });
|
|
// 어제 날짜의 stale 캐시를 심어둔다.
|
|
await seedCache("current", {
|
|
streakDays: 99,
|
|
highestStreak: 99,
|
|
weeklyResults: [null, null, null, null, null, null, null],
|
|
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
|
|
totalPredictions: 0,
|
|
totalCorrect: 0,
|
|
weeklyPredictions: 0,
|
|
tier: "BW",
|
|
tierPoints: 0,
|
|
updatedAt: Date.now() - 86_400_000,
|
|
forDate: yesterday,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.forDate).toBe(today);
|
|
expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 통산 집계는 voteHistory 전수 스캔을 대체하므로, 백필 값과 이후 증분이
|
|
* 스캔 결과와 동일해야 한다(이중 계상·누락 없이).
|
|
*/
|
|
describe("statsService.getStats — 통산 롤링 집계", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await rtdb.ref(`/userVotes/${uid}`).remove();
|
|
});
|
|
|
|
async function seedHistory(
|
|
date: DateString,
|
|
results: Array<boolean | null>
|
|
): Promise<void> {
|
|
await firestore
|
|
.collection("users").doc(uid)
|
|
.collection("voteHistory").doc(date)
|
|
.set({
|
|
data: results.map((result, i) => ({
|
|
gameId: `${date.replace(/-/g, "")}G${i}`,
|
|
team: "LG",
|
|
// null = 취소 무효표(result 없음) — 승률 모집단에서 제외되어야 한다
|
|
...(result === null ? { cancelled: true } : { result }),
|
|
})),
|
|
judgment: "success",
|
|
});
|
|
}
|
|
|
|
it("카운터가 없으면 전수 스캔으로 산출하고 user doc에 백필한다", async () => {
|
|
const today = todayKst();
|
|
await seedUser();
|
|
await seedHistory(addDays(today, -2), [true, false, true]);
|
|
await seedHistory(addDays(today, -1), [true, null]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
// [true,false,true] + [true, 취소] → 유효표 4건, 적중 3건 (취소 무효표 제외)
|
|
expect(stats.totalPredictions).toBe(4);
|
|
expect(stats.totalCorrect).toBe(3);
|
|
expect(stats.winRates.overall).toBeCloseTo(3 / 4);
|
|
|
|
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
|
expect(user.lifetimePredictions).toBe(4);
|
|
expect(user.lifetimeCorrect).toBe(3);
|
|
// 기준선은 스캔에 포함된 마지막 날짜
|
|
expect(user.lifetimeStatsThrough).toBe(addDays(today, -1));
|
|
});
|
|
|
|
it("백필된 카운터가 있으면 그 값을 그대로 쓴다(재스캔 없음)", async () => {
|
|
await seedUser({
|
|
lifetimePredictions: 40,
|
|
lifetimeCorrect: 25,
|
|
lifetimeStatsThrough: addDays(todayKst(), -1),
|
|
});
|
|
// 카운터를 쓰는지 확인하려고 이력과 어긋나는 값을 심는다
|
|
await seedHistory(addDays(todayKst(), -2), [true]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.totalPredictions).toBe(40);
|
|
expect(stats.totalCorrect).toBe(25);
|
|
});
|
|
|
|
/**
|
|
* 이력이 없을 때 기준선을 오늘로 잡으면, 아직 아카이브되지 않은 어제 투표가
|
|
* `date > through` 조건에 걸려 영구히 누락되고 stale 검사로도 복구되지 않는다.
|
|
*/
|
|
it("이력이 없으면 기준선을 세우지 않는다", async () => {
|
|
await seedUser();
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.totalPredictions).toBe(0);
|
|
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
|
expect(user.lifetimeStatsThrough).toBeUndefined();
|
|
expect(user.lifetimePredictions).toBeUndefined();
|
|
});
|
|
|
|
it("아카이브 전 조회 후 어제가 판정돼도 그날 예측이 누락되지 않는다", async () => {
|
|
const yesterday = addDays(todayKst(), -1);
|
|
await seedUser();
|
|
|
|
// 1) 어제 처음 투표한 유저가 아카이브(03:00) 전에 통계를 조회한다
|
|
await getStats(uid);
|
|
|
|
// 2) 이후 아카이브가 어제를 판정해 voteHistory를 기록한다
|
|
await seedHistory(yesterday, [true, false]);
|
|
await firestore.collection("users").doc(uid)
|
|
.set({ lastJudgedDate: yesterday }, { merge: true });
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
|
|
// 3) 다시 조회하면 어제 예측이 통산에 반영돼 있어야 한다
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.totalPredictions).toBe(2);
|
|
expect(stats.totalCorrect).toBe(1);
|
|
});
|
|
|
|
it("기준선을 lastJudgedDate로 앞당기지 않는다", async () => {
|
|
const today = todayKst();
|
|
const d1 = addDays(today, -2);
|
|
const d2 = addDays(today, -1);
|
|
// 판정 트랜잭션은 커밋됐지만(setDay 이전) voteHistory에는 d2가 아직 없는 상태
|
|
await seedUser({ lastJudgedDate: d2 });
|
|
await seedHistory(d1, [true]);
|
|
|
|
await getStats(uid);
|
|
|
|
// 기준선이 d2로 올라가면 d2 예측이 영영 집계되지 않는다 — d1이어야 한다
|
|
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
|
expect(user.lifetimeStatsThrough).toBe(d1);
|
|
});
|
|
|
|
it("기준선이 lastJudgedDate보다 뒤처지면 재스캔해 자가치유한다", async () => {
|
|
const today = todayKst();
|
|
const d1 = addDays(today, -2);
|
|
const d2 = addDays(today, -1);
|
|
// 백필이 d1까지만 반영된 상태에서 d2 판정이 증분되지 못한 상황을 재현한다
|
|
// (백필 스캔이 d2 문서를 보기 전에 판정 트랜잭션이 커밋되면 발생한다)
|
|
await seedUser({
|
|
lifetimePredictions: 1,
|
|
lifetimeCorrect: 1,
|
|
lifetimeStatsThrough: d1,
|
|
lastJudgedDate: d2,
|
|
});
|
|
await seedHistory(d1, [true]);
|
|
await seedHistory(d2, [true, false]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
// 재스캔되어 d2까지 반영돼야 한다
|
|
expect(stats.totalPredictions).toBe(3);
|
|
expect(stats.totalCorrect).toBe(2);
|
|
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
|
expect(user.lifetimeStatsThrough).toBe(d2);
|
|
});
|
|
|
|
it("기준선이 lastJudgedDate와 같으면 재스캔하지 않는다", async () => {
|
|
const d = addDays(todayKst(), -1);
|
|
await seedUser({
|
|
lifetimePredictions: 40,
|
|
lifetimeCorrect: 25,
|
|
lifetimeStatsThrough: d,
|
|
lastJudgedDate: d,
|
|
});
|
|
await seedHistory(d, [true]); // 카운터와 어긋나는 이력을 심어도 무시돼야 한다
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.totalPredictions).toBe(40);
|
|
});
|
|
});
|
|
|
|
describe("statsService.getStats — period 검증·정규화", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await seedUser();
|
|
});
|
|
|
|
it("서비스 개시 이전 연도는 거부한다", async () => {
|
|
await expect(getStats(uid, "1999")).rejects.toThrow(/invalid period/);
|
|
});
|
|
|
|
it("미래 연도는 거부한다", async () => {
|
|
const nextYear = Number(todayKst().slice(0, 4)) + 1;
|
|
await expect(getStats(uid, String(nextYear))).rejects.toThrow(/invalid period/);
|
|
});
|
|
|
|
it("잘못된 월은 거부한다", async () => {
|
|
const year = todayKst().slice(0, 4);
|
|
await expect(getStats(uid, `${year}-13`)).rejects.toThrow(/invalid period/);
|
|
});
|
|
|
|
it("미래 날짜는 거부한다", async () => {
|
|
await expect(getStats(uid, addDays(todayKst(), 1))).rejects.toThrow(/invalid period/);
|
|
});
|
|
|
|
it("같은 주의 서로 다른 날짜는 하나의 캐시 키로 접힌다", async () => {
|
|
const today = todayKst();
|
|
await getStats(uid, today);
|
|
const keys = Object.keys(
|
|
(await rtdb.ref(`/cache/stats/${uid}`).get()).val() ?? {}
|
|
);
|
|
// 원본 날짜 문자열이 아니라 주 단위 정규형(w<화요일>)으로 저장된다
|
|
expect(keys.some((k) => k.startsWith("w"))).toBe(true);
|
|
expect(keys).not.toContain(today);
|
|
});
|
|
});
|