mmday-firebase/tests/services/judgmentService.test.ts
윤정민 c964572a9c Replace point ledger with wallet engine and rework reward earning
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
2026-07-16 14:25:42 +09:00

390 lines
12 KiB
TypeScript

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<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);
}
}
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"));
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);
});
});
});