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 { runDailyArchive } from "../../src/scheduled/dailyArchive"; import type { DateString } from "../../src/types/dateString"; import type { Game, User } from "../../src/types/panit"; const date = "2026-05-12" as DateString; const gameId = "20260512HTLG0"; async function seedGame(): Promise { const doc: Game = { time: Timestamp.fromDate(new Date(Date.UTC(2026, 4, 12, 9, 0))), stadium: "잠실", status: "completed", homeTeamCode: "LG", awayTeamCode: "HT", winningTeamCode: "LG", }; await firestore.collection("games").doc(gameId).set(doc); invalidateAllGameDays(); } async function seedUser(uid: string): Promise { const user: Partial = { displayName: uid, email: `${uid}@e.com`, provider: "google", knowledgeLevel: "casual", active: true, createdAt: Timestamp.now(), }; await firestore.collection("users").doc(uid).set(user); } /** 판정이 끝난 투표를 원본에 심는다. reconcile 경로를 타지 않게 result를 채운다. */ async function seedRawVote(uid: string): Promise { await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).set({ team: "LG", result: true }); } /** 날짜별 인덱스에도 심는다(= 미러 배포 이후에 투표한 유저). */ async function seedIndexedVote(uid: string): Promise { await rtdb.ref(`/userVotesByDate/${date}/${uid}/${gameId}`).set({ team: "LG", result: true }); } async function hasHistory(uid: string): Promise { const snap = await firestore .collection("users").doc(uid) .collection("voteHistory").doc(date) .get(); return snap.exists; } describe("runDailyArchive — 날짜 인덱스 롤아웃", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("games")); invalidateAllGameDays(); await rtdb.ref("/userVotes").remove(); await rtdb.ref("/userVotesByDate").remove(); await rtdb.ref("/userVotesByDateMeta").remove(); await seedGame(); }); /** * 미러 배포 당일에는 인덱스가 부분적으로만 찬다 — 배포 전 투표자는 원본에만, * 배포 후 투표자는 양쪽에 있다. 인덱스가 비지 않았다는 이유로 원본을 건너뛰면 * 배포 전 투표자가 영구 유실된다(그 날짜는 다시 처리되지 않는다). */ it("인덱스가 부분적으로만 찼으면 원본에서 누락 유저를 보충한다", async () => { await seedUser("before-deploy"); await seedUser("after-deploy"); // 배포 전 투표자 — 원본에만 존재 await seedRawVote("before-deploy"); // 배포 후 투표자 — 원본 + 인덱스 await seedRawVote("after-deploy"); await seedIndexedVote("after-deploy"); const result = await runDailyArchive(date); expect(result.archived).toBe(2); expect(result.judgedUids.sort()).toEqual(["after-deploy", "before-deploy"]); expect(await hasHistory("before-deploy")).toBe(true); expect(await hasHistory("after-deploy")).toBe(true); }); it("인덱스가 완전히 비어도 원본만으로 아카이브한다", async () => { await seedUser("legacy-only"); await seedRawVote("legacy-only"); const result = await runDailyArchive(date); expect(result.archived).toBe(1); expect(await hasHistory("legacy-only")).toBe(true); }); it("백필 마커가 있으면 인덱스만 신뢰한다(원본 전체 스캔 안 함)", async () => { await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); await seedUser("indexed"); await seedUser("stale-raw"); await seedIndexedVote("indexed"); await seedRawVote("indexed"); // 인덱스에 없는 원본 잔재 — 백필 완료 후에는 보충 대상이 아니다 await seedRawVote("stale-raw"); const result = await runDailyArchive(date); expect(result.judgedUids).toEqual(["indexed"]); expect(await hasHistory("stale-raw")).toBe(false); }); it("양쪽 모두 비어 있으면 아무것도 아카이브하지 않는다", async () => { const result = await runDailyArchive(date); expect(result.archived).toBe(0); expect(result.judgedUids).toEqual([]); }); it("아카이브 후 원본과 날짜별 미러를 모두 정리한다", async () => { await seedUser("cleanup"); await seedRawVote("cleanup"); await seedIndexedVote("cleanup"); await runDailyArchive(date); expect((await rtdb.ref(`/userVotes/cleanup/${date}`).get()).exists()).toBe(false); expect((await rtdb.ref(`/userVotesByDate/${date}/cleanup`).get()).exists()).toBe(false); }); });