mmday-firebase/tests/repositories/voteRepository.test.ts
윤정민 eb44f50886 Index user votes by date for daily archive
- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
2026-07-27 13:26:15 +09:00

266 lines
9.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { rtdb } from "../../src/firebase";
import {
changeVote,
deleteGameVotes,
getAllUserVotes,
getCounts,
getUserDateVotes,
getUserVote,
getVotesByDate,
isVoteDateIndexBackfilled,
deleteUserVoteGame,
deleteUserVoteIndex,
submitVote,
} from "../../src/repositories/voteRepository";
import type { DateString } from "../../src/types/dateString";
const gameId = "20260412HTLG0";
const gameId2 = "20260412WOSK0";
const uid = "test-uid-1";
const uid2 = "test-uid-2";
const date = "2026-04-12" as DateString;
describe("voteRepository (RTDB)", () => {
beforeEach(async () => {
console.log("[setup] /votes, /userVotes 초기화");
await rtdb.ref("/votes").remove();
await rtdb.ref("/userVotes").remove();
});
describe("submitVote", () => {
it("home 투표 시 homeCount가 1 증가하고 인덱스가 저장된다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
const counts = await getCounts(gameId);
const userVote = await getUserVote(gameId, uid);
const dateVotes = await getUserDateVotes(uid, date);
console.log("[home vote] counts:", counts);
console.log("[home vote] userVote:", userVote);
console.log("[home vote] dateVotes:", dateVotes);
expect(counts).toEqual({ homeCount: 1, awayCount: 0, drawCount: 0 });
expect(userVote).toEqual({ team: "LG" });
expect(dateVotes[gameId]).toEqual({ team: "LG" });
});
it("away 투표 시 awayCount만 증가한다", async () => {
await submitVote({ gameId, uid, date, side: "away", team: "KIA" });
const counts = await getCounts(gameId);
console.log("[away vote] counts:", counts);
expect(counts).toEqual({ homeCount: 0, awayCount: 1, drawCount: 0 });
});
it("무승부 투표 시 drawCount만 증가하고 team은 DRAW로 저장된다", async () => {
await submitVote({ gameId, uid, date, side: "draw", team: "DRAW" });
const counts = await getCounts(gameId);
const userVote = await getUserVote(gameId, uid);
console.log("[draw vote] counts:", counts);
expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 1 });
expect(userVote).toEqual({ team: "DRAW" });
});
});
describe("changeVote", () => {
it("home→away 변경 시 카운트가 재분배되고 인덱스가 갱신된다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
console.log("[change] 초기:", await getCounts(gameId));
await changeVote({
gameId,
uid,
date,
oldSide: "home",
newSide: "away",
newTeam: "KIA",
});
const counts = await getCounts(gameId);
const userVote = await getUserVote(gameId, uid);
const dateVotes = await getUserDateVotes(uid, date);
console.log("[change] 변경 후 counts:", counts);
console.log("[change] userVote:", userVote);
expect(counts).toEqual({ homeCount: 0, awayCount: 1, drawCount: 0 });
expect(userVote).toEqual({ team: "KIA" });
expect(dateVotes[gameId]).toEqual({ team: "KIA" });
});
it("home→draw 변경 시 drawCount로 재분배된다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await changeVote({
gameId,
uid,
date,
oldSide: "home",
newSide: "draw",
newTeam: "DRAW",
});
const counts = await getCounts(gameId);
const userVote = await getUserVote(gameId, uid);
console.log("[change→draw] counts:", counts);
expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 1 });
expect(userVote).toEqual({ team: "DRAW" });
});
});
describe("getUserVote", () => {
it("존재하지 않으면 null을 반환한다", async () => {
const result = await getUserVote(gameId, "nonexistent-uid");
expect(result).toBeNull();
});
});
describe("getCounts", () => {
it("counts 노드가 없으면 0으로 채워 반환한다", async () => {
const result = await getCounts("nonexistent-game");
console.log("[counts-empty]", result);
expect(result).toEqual({ homeCount: 0, awayCount: 0, drawCount: 0 });
});
});
describe("getAllUserVotes", () => {
it("여러 유저의 투표를 맵으로 반환한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" });
const all = await getAllUserVotes(gameId);
console.log("[all votes]", all);
expect(Object.keys(all).sort()).toEqual([uid, uid2].sort());
expect(all[uid]).toEqual({ team: "LG" });
expect(all[uid2]).toEqual({ team: "KIA" });
});
it("투표가 없으면 빈 객체를 반환한다", async () => {
const result = await getAllUserVotes("nonexistent-game");
expect(result).toEqual({});
});
});
describe("getUserDateVotes", () => {
it("특정 날짜의 여러 경기 투표를 맵으로 반환한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({
gameId: gameId2,
uid,
date,
side: "away",
team: "키움",
});
const result = await getUserDateVotes(uid, date);
console.log("[date votes]", result);
expect(Object.keys(result).sort()).toEqual([gameId, gameId2].sort());
expect(result[gameId]).toEqual({ team: "LG" });
expect(result[gameId2]).toEqual({ team: "키움" });
});
});
describe("deleteGameVotes", () => {
it("/votes/{gameId}는 삭제되지만 /userVotes 인덱스는 남는다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await deleteGameVotes(gameId);
const counts = await getCounts(gameId);
const users = await getAllUserVotes(gameId);
const dateVotes = await getUserDateVotes(uid, date);
console.log("[delete] counts:", counts);
console.log("[delete] users:", users);
console.log("[delete] dateVotes (남아있음):", dateVotes);
expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 0 });
expect(users).toEqual({});
expect(dateVotes[gameId]).toEqual({ team: "LG" });
});
});
});
/**
* 날짜별 인덱스는 `/userVotes`의 (date, uid) 전치 미러다. dailyArchive가 이쪽만
* 읽으므로, 원본을 바꾸는 모든 경로가 미러도 같이 갱신해야 투표가 유실되지 않는다.
*/
describe("voteRepository — /userVotesByDate 미러", () => {
beforeEach(async () => {
await rtdb.ref("/votes").remove();
await rtdb.ref("/userVotes").remove();
await rtdb.ref("/userVotesByDate").remove();
});
it("submitVote가 날짜별 인덱스에도 기록한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
const byDate = await getVotesByDate(date);
expect(byDate[uid]?.[gameId]).toEqual({ team: "LG" });
});
it("여러 유저의 같은 날짜 투표가 한 노드에 모인다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" });
await submitVote({ gameId: gameId2, uid, date, side: "draw", team: "DRAW" });
const byDate = await getVotesByDate(date);
expect(Object.keys(byDate).sort()).toEqual([uid, uid2].sort());
expect(Object.keys(byDate[uid])).toHaveLength(2);
});
it("changeVote가 미러의 팀도 함께 바꾼다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await changeVote({
gameId, uid, date, oldSide: "home", newSide: "away", newTeam: "KIA",
});
const byDate = await getVotesByDate(date);
expect(byDate[uid][gameId]).toEqual({ team: "KIA" });
});
it("deleteUserVoteGame이 양쪽에서 제거한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId: gameId2, uid, date, side: "away", team: "KIA" });
await deleteUserVoteGame(uid, date, gameId);
expect(await getUserDateVotes(uid, date)).toEqual({ [gameId2]: { team: "KIA" } });
const byDate = await getVotesByDate(date);
expect(byDate[uid]).toEqual({ [gameId2]: { team: "KIA" } });
});
it("deleteUserVoteIndex가 그 유저의 모든 날짜 미러를 지운다", async () => {
const other = "2026-04-13" as DateString;
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId: gameId2, uid, date: other, side: "away", team: "KIA" });
await submitVote({ gameId, uid: uid2, date, side: "home", team: "LG" });
await deleteUserVoteIndex(uid);
expect(await getUserDateVotes(uid, date)).toEqual({});
expect((await getVotesByDate(date))[uid]).toBeUndefined();
expect((await getVotesByDate(other))[uid]).toBeUndefined();
// 다른 유저 기록은 남아 있어야 한다
expect((await getVotesByDate(date))[uid2]).toEqual({ [gameId]: { team: "LG" } });
});
it("투표가 없는 날짜는 빈 객체를 돌려준다", async () => {
expect(await getVotesByDate("2026-01-01" as DateString)).toEqual({});
});
});
describe("voteRepository — 백필 완료 표시", () => {
beforeEach(async () => {
await rtdb.ref("/userVotesByDateMeta").remove();
});
it("표시가 없으면 false", async () => {
expect(await isVoteDateIndexBackfilled()).toBe(false);
});
it("표시가 있으면 true — dailyArchive가 레거시 폴백을 건너뛰는 근거", async () => {
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
expect(await isVoteDateIndexBackfilled()).toBe(true);
});
});