- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
236 lines
7.0 KiB
TypeScript
236 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { Timestamp } from "firebase-admin/firestore";
|
|
import { firestore, rtdb } from "../../src/firebase";
|
|
import { getStats } from "../../src/services/statsService";
|
|
import {
|
|
addDaysKst,
|
|
todayKst,
|
|
type DateString,
|
|
} from "../../src/types/dateString";
|
|
import type {
|
|
Game,
|
|
GameStatus,
|
|
StatsResponse,
|
|
User,
|
|
} from "../../src/types/panit";
|
|
|
|
const uid = "stats-uid";
|
|
|
|
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 [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: spec.status,
|
|
homeTeamCode: "LG",
|
|
awayTeamCode: "HT",
|
|
};
|
|
if (spec.winner) doc.winningTeamCode = spec.winner;
|
|
await firestore.collection("games").doc(gameId).set(doc);
|
|
}
|
|
}
|
|
|
|
async function seedUser(patch: Partial<User> = {}): Promise<void> {
|
|
await firestore
|
|
.collection("users")
|
|
.doc(uid)
|
|
.set(
|
|
{
|
|
displayName: "stats-user",
|
|
email: "s@e.com",
|
|
provider: "google",
|
|
knowledgeLevel: "beginner",
|
|
createdAt: Timestamp.now(),
|
|
...patch,
|
|
},
|
|
{ merge: true }
|
|
);
|
|
}
|
|
|
|
async function seedCache(
|
|
key: string,
|
|
partial: Partial<StatsResponse>
|
|
): Promise<void> {
|
|
await rtdb.ref(`/cache/stats/${uid}/${key}`).set(partial);
|
|
}
|
|
|
|
describe("statsService.getStats — 결석 lazy 보정", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await firestore.recursiveDelete(firestore.collection("games"));
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await rtdb.ref(`/userVotes/${uid}`).remove();
|
|
});
|
|
|
|
it("lastJudgedDate가 이틀 이상 이전이고 gap에 실제 경기일이 있으면 streak 0", async () => {
|
|
const today = todayKst();
|
|
const threeDaysAgo = addDaysKst(today, -3);
|
|
const twoDaysAgo = addDaysKst(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 7,
|
|
lastJudgedDate: threeDaysAgo,
|
|
tierPoints: 100,
|
|
});
|
|
// gap에 실제 경기일을 둔다 → 결석으로 판정되어야 함.
|
|
await seedGames(twoDaysAgo, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
expect(stats.highestStreak).toBe(7); // 최고 streak은 그대로
|
|
expect(stats.forDate).toBe(today);
|
|
});
|
|
|
|
it("gap에 실제 경기일이 없으면(전부 휴장) streak 유지", async () => {
|
|
const today = todayKst();
|
|
const threeDaysAgo = addDaysKst(today, -3);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 7,
|
|
lastJudgedDate: threeDaysAgo,
|
|
});
|
|
// games 컬렉션 비움 — 모든 gap 날이 0건이므로 skip 처리, 결석 아님.
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("lastJudgedDate가 어제(D-1)면 streak 유지", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDaysKst(today, -1);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: yesterday,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("lastJudgedDate가 D-2여도 어제분 userVotes가 남아 있으면 archive 대기로 보고 streak 보호", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDaysKst(today, -1);
|
|
const twoDaysAgo = addDaysKst(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: twoDaysAgo,
|
|
});
|
|
// 어제 투표는 했으나 아직 dailyArchive가 안 돈 상황을 시뮬레이트.
|
|
await rtdb
|
|
.ref(`/userVotes/${uid}/${yesterday}/g1`)
|
|
.set({ team: "LG" });
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(5);
|
|
});
|
|
|
|
it("어제 경기가 있었는데 userVotes 비어 있으면(진짜 결석) streak 0", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDaysKst(today, -1);
|
|
const twoDaysAgo = addDaysKst(today, -2);
|
|
await seedUser({
|
|
currentStreak: 5,
|
|
highestStreak: 5,
|
|
lastJudgedDate: twoDaysAgo,
|
|
});
|
|
// 어제 실제 경기가 있었음 + userVotes 없음 = 진짜 결석.
|
|
await seedGames(yesterday, [
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
{ status: "completed", winner: "LG" },
|
|
]);
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
});
|
|
|
|
it("lastJudgedDate가 없는 신규 유저는 streak 0", async () => {
|
|
await seedUser({
|
|
tierPoints: 0,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("statsService.getStats — 캐시 forDate 검증", () => {
|
|
beforeEach(async () => {
|
|
await firestore.recursiveDelete(firestore.collection("users"));
|
|
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
|
await rtdb.ref(`/userVotes/${uid}`).remove();
|
|
});
|
|
|
|
it("캐시 forDate가 오늘이면 그대로 반환한다", async () => {
|
|
const today = todayKst();
|
|
await seedUser({ currentStreak: 99, lastJudgedDate: today });
|
|
// 일부러 streakDays를 비현실적인 값으로 캐시에 저장 → 캐시 hit이면 그대로 노출
|
|
await seedCache("current", {
|
|
streakDays: 12345,
|
|
highestStreak: 12345,
|
|
weeklyResults: [null, null, null, null, null, null, null],
|
|
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
|
|
totalPredictions: 0,
|
|
totalCorrect: 0,
|
|
weeklyPredictions: 0,
|
|
currentLevel: 1,
|
|
progress: 0,
|
|
tier: "Bronze",
|
|
tierPoints: 0,
|
|
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
|
|
updatedAt: Date.now(),
|
|
forDate: today,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.streakDays).toBe(12345);
|
|
});
|
|
|
|
it("캐시 forDate가 어제면 무효화하고 재계산한다", async () => {
|
|
const today = todayKst();
|
|
const yesterday = addDaysKst(today, -1);
|
|
await seedUser({ currentStreak: 3, lastJudgedDate: yesterday });
|
|
// 어제 날짜의 stale 캐시를 심어둔다.
|
|
await seedCache("current", {
|
|
streakDays: 99,
|
|
highestStreak: 99,
|
|
weeklyResults: [null, null, null, null, null, null, null],
|
|
winRates: { overall: 0, weekly: 0, monthly: 0, season: 0 },
|
|
totalPredictions: 0,
|
|
totalCorrect: 0,
|
|
weeklyPredictions: 0,
|
|
currentLevel: 1,
|
|
progress: 0,
|
|
tier: "Bronze",
|
|
tierPoints: 0,
|
|
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
|
|
updatedAt: Date.now() - 86_400_000,
|
|
forDate: yesterday,
|
|
});
|
|
|
|
const stats = await getStats(uid);
|
|
|
|
expect(stats.forDate).toBe(today);
|
|
expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산
|
|
});
|
|
});
|