- 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건(신규 파일)
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { Timestamp } from "firebase-admin/firestore";
|
|
import { firestore } from "../../src/firebase";
|
|
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
|
|
import {
|
|
judgeDay,
|
|
} from "../../src/services/judgmentService";
|
|
import { setDay } from "../../src/repositories/voteHistoryRepository";
|
|
import type { DateString } from "../../src/types/dateString";
|
|
import type {
|
|
DailyJudgment,
|
|
Game,
|
|
GameStatus,
|
|
User,
|
|
VoteHistoryDoc,
|
|
} from "../../src/types/panit";
|
|
|
|
const uid = "judge-uid";
|
|
|
|
/** 2026-04-14는 화요일 (KST) */
|
|
const TUE: DateString = "2026-04-14" as DateString;
|
|
const WED: DateString = "2026-04-15" as DateString;
|
|
const THU: DateString = "2026-04-16" as DateString;
|
|
const FRI: DateString = "2026-04-17" as DateString;
|
|
const SAT: DateString = "2026-04-18" as DateString;
|
|
const SUN: DateString = "2026-04-19" as DateString;
|
|
|
|
function makeGame(
|
|
date: DateString,
|
|
status: GameStatus,
|
|
homeTeamCode: string,
|
|
winner: string | null = null
|
|
): Game {
|
|
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,
|
|
homeTeamCode,
|
|
awayTeamCode: "HT",
|
|
};
|
|
if (winner) doc.winningTeamCode = winner;
|
|
return doc;
|
|
}
|
|
|
|
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 game = makeGame(date, spec.status, "LG", spec.winner ?? null);
|
|
await firestore.collection("games").doc(gameId).set(game);
|
|
invalidateAllGameDays();
|
|
}
|
|
}
|
|
|
|
function voteDocOf(correct: number, total: number): VoteHistoryDoc {
|
|
const data = [];
|
|
for (let i = 0; i < total; i++) {
|
|
data.push({
|
|
gameId: `g${i}`,
|
|
team: "LG",
|
|
result: i < correct,
|
|
});
|
|
}
|
|
return { data };
|
|
}
|
|
|
|
async function seedUser(patch: Partial<User> = {}): Promise<void> {
|
|
await firestore
|
|
.collection("users")
|
|
.doc(uid)
|
|
.set(
|
|
{
|
|
displayName: "judge",
|
|
email: "j@e.com",
|
|
provider: "google",
|
|
knowledgeLevel: "beginner",
|
|
createdAt: Timestamp.now(),
|
|
...patch,
|
|
},
|
|
{ merge: true }
|
|
);
|
|
}
|
|
|
|
async function readUser(): Promise<Partial<User>> {
|
|
const snap = await firestore.collection("users").doc(uid).get();
|
|
return (snap.data() ?? {}) as Partial<User>;
|
|
}
|
|
|
|
async function readVoteHistory(date: DateString): Promise<VoteHistoryDoc> {
|
|
const snap = await firestore
|
|
.collection("users")
|
|
.doc(uid)
|
|
.collection("voteHistory")
|
|
.doc(date)
|
|
.get();
|
|
return (snap.data() ?? { data: [] }) as VoteHistoryDoc;
|
|
}
|
|
|
|
async function seedJudgedHistory(
|
|
date: DateString,
|
|
judgment: DailyJudgment
|
|
): Promise<void> {
|
|
await setDay(uid, date, { data: [], judgment });
|
|
}
|
|
|
|
describe("judgmentService (Firestore emulator)", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await firestore.recursiveDelete(firestore.collection("games"));
|
|
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
|
|
invalidateAllGameDays();
|
|
await seedUser();
|
|
});
|
|
|
|
describe("judgeDay — 정상 5경기", () => {
|
|
it("5경기 모두 종료·5적중이면 perfect 판정과 dailyAllKill 티켓 지급", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
const vote = voteDocOf(5, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
const hist = await readVoteHistory(TUE);
|
|
|
|
expect(hist.judgment).toBe("perfect");
|
|
expect(hist.correctCount).toBe(5);
|
|
expect(hist.completedCount).toBe(5);
|
|
expect(hist.streakAfter).toBe(1);
|
|
expect(user.currentStreak).toBe(1);
|
|
expect(user.highestStreak).toBe(1);
|
|
expect(user.tierPoints).toBe(51); // 5 * 10 + 스트릭 보너스 min(1, 15)
|
|
expect(user.lastJudgedDate).toBe(TUE);
|
|
});
|
|
|
|
it("5경기·3적중은 success, 스트릭 +1, 티켓 미지급", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
const vote = voteDocOf(3, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
const hist = await readVoteHistory(TUE);
|
|
|
|
expect(hist.judgment).toBe("success");
|
|
expect(user.currentStreak).toBe(1);
|
|
expect(user.tierPoints).toBe(31); // 3 * 10 + 스트릭 보너스 1
|
|
});
|
|
|
|
it("5경기·2적중은 fail, 스트릭 0 유지, 포인트 불변", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
await seedUser({ currentStreak: 3, highestStreak: 3, tierPoints: 100 });
|
|
const vote = voteDocOf(2, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(0);
|
|
expect(user.highestStreak).toBe(3); // 기존 최고치 유지
|
|
expect(user.tierPoints).toBe(100); // 감점 없음
|
|
});
|
|
});
|
|
|
|
describe("judgeDay — 일부 취소 (thresholds 일반화)", () => {
|
|
it("3 completed + 2 cancelled, 2적중이면 success (success=2, perfect=3)", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "cancelled" },
|
|
{ status: "cancelled" },
|
|
]);
|
|
const vote = voteDocOf(2, 3);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const hist = await readVoteHistory(TUE);
|
|
expect(hist.judgment).toBe("success");
|
|
expect(hist.completedCount).toBe(3);
|
|
});
|
|
|
|
it("3 completed, 3적중이면 perfect → dailyAllKill 지급", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
const vote = voteDocOf(3, 3);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
const hist = await readVoteHistory(TUE);
|
|
expect(hist.judgment).toBe("perfect");
|
|
});
|
|
|
|
it("4 completed, 2적중이면 success (success=2, perfect=4)", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
const vote = voteDocOf(2, 4);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const hist = await readVoteHistory(TUE);
|
|
expect(hist.judgment).toBe("success");
|
|
});
|
|
});
|
|
|
|
describe("judgeDay — skip (completed ≤ 2)", () => {
|
|
it("2 completed만 있으면 skip, 스트릭 유지, 포인트 불변", async () => {
|
|
await seedUser({ currentStreak: 4, tierPoints: 80, highestStreak: 4 });
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "cancelled" },
|
|
]);
|
|
const vote = voteDocOf(1, 2);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
const hist = await readVoteHistory(TUE);
|
|
|
|
expect(hist.judgment).toBe("skip");
|
|
expect(hist.streakAfter).toBe(4);
|
|
expect(user.currentStreak).toBe(4);
|
|
expect(user.tierPoints).toBe(80);
|
|
});
|
|
});
|
|
|
|
describe("judgeDay — 결석 후 복귀 시 streak 리셋", () => {
|
|
const SAT: DateString = "2026-04-11" as DateString; // TUE - 3일
|
|
const SUN_PREV: DateString = "2026-04-12" as DateString; // TUE - 2일
|
|
const MON_PREV: DateString = "2026-04-13" as DateString; // TUE - 1일 (KBO 휴장일)
|
|
|
|
function fiveCompleted(): Array<{ status: GameStatus; winner: string }> {
|
|
return Array.from({ length: 5 }, () => ({
|
|
status: "completed" as GameStatus,
|
|
winner: "LG",
|
|
}));
|
|
}
|
|
|
|
it("결석 사이에 실제 경기일이 있으면 success여도 streak이 1에서 시작", async () => {
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: SAT,
|
|
});
|
|
// (SAT, TUE) 사이에 SUN(경기 5건), MON(휴장) — SUN은 진짜 경기일.
|
|
await seedGames(SUN_PREV, fiveCompleted());
|
|
await seedGames(TUE, fiveCompleted());
|
|
const vote = voteDocOf(3, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
const hist = await readVoteHistory(TUE);
|
|
expect(hist.streakAfter).toBe(1);
|
|
expect(user.currentStreak).toBe(1);
|
|
expect(user.highestStreak).toBe(5);
|
|
});
|
|
|
|
it("결석 후 복귀일이 fail이어도 streak은 0", async () => {
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: SAT,
|
|
});
|
|
await seedGames(SUN_PREV, fiveCompleted());
|
|
await seedGames(TUE, fiveCompleted());
|
|
const vote = voteDocOf(2, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(0);
|
|
});
|
|
|
|
it("lastJudgedDate가 직전날이면 success가 누적되어 +1 (회귀 확인)", async () => {
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: MON_PREV,
|
|
});
|
|
await seedGames(TUE, fiveCompleted());
|
|
const vote = voteDocOf(3, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(6);
|
|
expect(user.highestStreak).toBe(6);
|
|
});
|
|
|
|
it("gap 안에 휴장일(KBO 월요일)만 있으면 streak 유지하고 누적", async () => {
|
|
// lastJudged = SUN, date = TUE, 그 사이는 MON 하나뿐인데 MON은 경기 0건 → skip 처리.
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: SUN_PREV,
|
|
});
|
|
// MON은 경기를 시드하지 않음(휴장일) — listByDate가 빈 배열 반환.
|
|
await seedGames(TUE, fiveCompleted());
|
|
const vote = voteDocOf(3, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(6);
|
|
expect(user.highestStreak).toBe(6);
|
|
});
|
|
|
|
it("gap의 경기들이 모두 ≤2 completed(skip 조건)이면 streak 유지", async () => {
|
|
// (SAT, TUE) 사이의 SUN은 cancelled 다수 + 2 completed → thresholdsFor=skip.
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: SAT,
|
|
});
|
|
await seedGames(SUN_PREV, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "cancelled" },
|
|
]);
|
|
await seedGames(TUE, fiveCompleted());
|
|
const vote = voteDocOf(3, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("judgeDay — 멱등성", () => {
|
|
it("같은 날짜에 두 번 호출해도 포인트·스트릭·티켓이 두 번 누적되지 않는다", async () => {
|
|
await seedGames(TUE, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
const vote = voteDocOf(5, 5);
|
|
await setDay(uid, TUE, vote);
|
|
|
|
await judgeDay(uid, TUE, vote);
|
|
await judgeDay(uid, TUE, vote);
|
|
|
|
const user = await readUser();
|
|
expect(user.currentStreak).toBe(1);
|
|
expect(user.tierPoints).toBe(51);
|
|
});
|
|
});
|
|
|
|
});
|