- `dailyArchive` 과정에 `judgeDay`를 통합하여 경기 결과에 따른 일간 판정, 스트릭 업데이트, 티어 포인트 가산을 자동화했습니다. - 주간 모든 경기를 성공적으로 예측했을 때 '주간 마스터 티켓'을 지급하는 `judgeWeekIfNeeded` 로직을 구현했습니다. - Firestore 트랜잭션을 사용하여 판정 결과 반영의 원자성을 확보하고, 중복 처리를 방지하는 멱등성 가드를 적용했습니다. - `statsService`가 실시간 계산 대신 사용자 문서에 저장된 스트릭과 티어 정보를 활용하도록 고도화하고 관련 유틸리티와 테스트 코드를 추가했습니다.
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { Timestamp } from "firebase-admin/firestore";
|
|
import { firestore } from "../../src/firebase.js";
|
|
import {
|
|
judgeDay,
|
|
judgeWeekIfNeeded,
|
|
} from "../../src/services/judgmentService.js";
|
|
import { setDay } from "../../src/repositories/voteHistoryRepository.js";
|
|
import type { DateString } from "../../src/types/dateString.js";
|
|
import type {
|
|
DailyJudgment,
|
|
Game,
|
|
GameStatus,
|
|
User,
|
|
VoteHistoryDoc,
|
|
} from "../../src/types/panit.js";
|
|
|
|
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.tickets?.dailyAllKill).toBe(1);
|
|
expect(user.tickets?.weeklyMaster).toBe(0);
|
|
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.tickets?.dailyAllKill).toBe(0);
|
|
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");
|
|
expect(user.tickets?.dailyAllKill).toBe(1);
|
|
});
|
|
|
|
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);
|
|
expect(user.tickets?.dailyAllKill).toBe(0);
|
|
});
|
|
});
|
|
|
|
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.tickets?.dailyAllKill).toBe(1);
|
|
expect(user.tierPoints).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("judgeWeekIfNeeded", () => {
|
|
async function seedWeekJudgments(
|
|
judgments: Record<DateString, DailyJudgment>
|
|
): Promise<void> {
|
|
for (const [date, j] of Object.entries(judgments)) {
|
|
await seedJudgedHistory(date as DateString, j);
|
|
}
|
|
}
|
|
|
|
it("화~일 6일 전부 success|perfect|skip 이면 weeklyMaster 지급", async () => {
|
|
await seedWeekJudgments({
|
|
[TUE]: "success",
|
|
[WED]: "perfect",
|
|
[THU]: "skip",
|
|
[FRI]: "success",
|
|
[SAT]: "success",
|
|
[SUN]: "success",
|
|
});
|
|
|
|
await judgeWeekIfNeeded(uid, SUN);
|
|
|
|
const user = await readUser();
|
|
expect(user.tickets?.weeklyMaster).toBe(1);
|
|
expect(user.lastWeeklyMasterTuesday).toBe(TUE);
|
|
});
|
|
|
|
it("한 날이라도 fail이면 지급 안 됨", async () => {
|
|
await seedWeekJudgments({
|
|
[TUE]: "success",
|
|
[WED]: "fail",
|
|
[THU]: "success",
|
|
[FRI]: "success",
|
|
[SAT]: "success",
|
|
[SUN]: "success",
|
|
});
|
|
|
|
await judgeWeekIfNeeded(uid, SUN);
|
|
|
|
const user = await readUser();
|
|
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
|
});
|
|
|
|
it("판정 이력이 누락된 날이 있으면 지급 안 됨", async () => {
|
|
await seedWeekJudgments({
|
|
[TUE]: "success",
|
|
[WED]: "success",
|
|
// THU 누락
|
|
[FRI]: "success",
|
|
[SAT]: "success",
|
|
[SUN]: "success",
|
|
});
|
|
|
|
await judgeWeekIfNeeded(uid, SUN);
|
|
|
|
const user = await readUser();
|
|
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
|
});
|
|
|
|
it("같은 주 재실행 시 중복 지급되지 않는다 (멱등)", async () => {
|
|
await seedWeekJudgments({
|
|
[TUE]: "success",
|
|
[WED]: "success",
|
|
[THU]: "success",
|
|
[FRI]: "success",
|
|
[SAT]: "success",
|
|
[SUN]: "success",
|
|
});
|
|
|
|
await judgeWeekIfNeeded(uid, SUN);
|
|
await judgeWeekIfNeeded(uid, SUN);
|
|
|
|
const user = await readUser();
|
|
expect(user.tickets?.weeklyMaster).toBe(1);
|
|
});
|
|
});
|
|
});
|