- /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건 추가
266 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|