import { beforeEach, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../../src/firebase"; 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 { 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); } } 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 = {}): Promise { 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> { const snap = await firestore.collection("users").doc(uid).get(); return (snap.data() ?? {}) as Partial; } async function readVoteHistory(date: DateString): Promise { 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 { await setDay(uid, date, { data: [], judgment }); } describe("judgmentService (Firestore emulator)", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("games")); 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(50); // 5 * 10, streak 1이라 보너스 0 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(30); }); 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(50); }); }); });