Implement lazy streak correction and enhance game result synchronization.

- 사용자의 마지막 참여일 이후 실제 경기가 있었음에도 참여하지 않은 경우를 감지하여 스트릭을 0으로 초기화하는 지연 보정(lazy correction) 로직을 도입했습니다.
- KBO 휴장일(월요일, 전체 우천 취소 등)을 자동으로 식별하여 실제 경기가 열린 날에만 결석 판정이 내려지도록 `hasMissedGameDayBetween` 검증 기능을 구현했습니다.
- 실시간 라이브 데이터를 기반으로 전일 경기 결과를 확정하는 기능을 추가하여 월간 일정 갱신 과정에서 발생할 수 있는 데이터 누락을 보완했습니다.
- 일일 아카이브 및 경기 동기화 로직을 수동으로 트리거할 수 있는 디버그 엔드포인트를 추가하고, 통계 데이터에 산출 기준일(forDate)을 포함하여 캐시 무효화 정밀도를 개선했습니다.
This commit is contained in:
윤정민 2026-05-06 11:14:28 +09:00
parent 5149ecf82a
commit 1a68d41e4f
14 changed files with 757 additions and 13 deletions

View File

@ -54,6 +54,15 @@
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
]
},
{
"collectionGroup": "users",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
{ "fieldPath": "tierPoints", "order": "ASCENDING" },
{ "fieldPath": "__name__", "order": "ASCENDING" }
]
}
],
"fieldOverrides": []

View File

@ -0,0 +1,54 @@
import { onRequest } from "firebase-functions/https";
import { logger } from "firebase-functions";
import { runDailyArchive } from "../scheduled/dailyArchive.js";
import { syncYesterdayFromLive } from "../services/gameSyncService.js";
import { sendError } from "../middleware/errors.js";
import { daysAgoKst, parseDateString } from "../types/dateString.js";
/**
* . .
*
* - GET/POST `/debug/dailyArchive` (KST) dailyArchive .
* - GET/POST `/debug/dailyArchive?date=YYYY-MM-DD` archive .
*/
export const debug = onRequest(async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
const tail = segs.slice(-1)[0];
try {
if (tail === "syncYesterday") {
const dateParam =
(typeof req.query.date === "string" && req.query.date) ||
(typeof req.body?.date === "string" && req.body.date) ||
undefined;
const dateString = dateParam
? parseDateString(dateParam)
: daysAgoKst(1);
const ymd = dateString.replace(/-/g, "");
logger.info(`debug.syncYesterday triggered manually (ymd=${ymd})`);
const result = await syncYesterdayFromLive(ymd);
res.status(200).json({ ymd, ...result });
return;
}
if (tail === "dailyArchive") {
const dateParam =
(typeof req.query.date === "string" && req.query.date) ||
(typeof req.body?.date === "string" && req.body.date) ||
undefined;
const overrideDate = dateParam ? parseDateString(dateParam) : undefined;
logger.info(
`debug.dailyArchive triggered manually (date=${overrideDate ?? "yesterday"})`
);
const result = await runDailyArchive(overrideDate);
res.status(200).json(result);
return;
}
res.status(404).json({ error: `Unknown: ${req.method} ${req.path}` });
} catch (err) {
sendError(res, err);
}
});

View File

@ -8,6 +8,7 @@ export { user } from "./handlers/userHandlers";
export { prediction } from "./handlers/predictionHandlers";
export { stats } from "./handlers/statsHandlers";
export { admin } from "./handlers/adminHandlers";
export { debug } from "./handlers/debugHandlers";
export { kboDailyRefresh } from "./scheduled/kboRefresh";
export { dailyArchive } from "./scheduled/dailyArchive";
export { onGameCompleted } from "./triggers/onGameCompleted";

View File

@ -167,7 +167,7 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
return THIRTY_SEC_MS;
}
function statusFromRecord(rec: GameListRecord): GameStatus | null {
export function statusFromRecord(rec: GameListRecord): GameStatus | null {
// cancelCode: "0" = 정상경기, 그 외 = 취소/노게임
if (rec.status.cancelCode && rec.status.cancelCode !== "0") return "cancelled";
// stateCode: "1" = 예정, "2" = 진행 중, "3" = 종료
@ -187,56 +187,94 @@ function liveKey(
return `${away}|${home}|${time}`;
}
/**
* (`games`) (·) .
*
*
* - (`ymd`) "오늘" API(`getGameList`) / .
* / .
* - 1 `gameId`, 2 `(awayTeamCode, homeTeamCode, time)` .
* `gameId` (: 일정만 ) .
* - API
* ( ) .
*
* @param ymd (YYYYMMDD). .
* @param games . .
* @param series (/ ). `getGameList` .
* @returns · . .
*/
async function mergeLiveIntoSchedule(
ymd: string,
games: ScheduleGame[],
series: string | undefined
): Promise<ScheduleGame[]> {
// 서버 로컬 타임존 기준 "오늘" 날짜. ymd와 비교해 실시간 병합 여부를 결정한다.
const today = formatYmd(new Date());
console.log(`[kbo-merge] enter ymd=${ymd} today=${today} games=${games.length}`);
// 오늘이 아닌 날짜는 실시간 정보가 의미 없으므로 외부 호출 없이 즉시 반환한다.
if (ymd !== today) {
console.log(`[kbo-merge] skip: not today`);
return games;
}
// 실시간 경기 목록 조회. 네트워크/파싱 실패 시 원본 일정으로 graceful degradation.
let live: GameListRecord[];
try {
const result = await getGameList(ymd, series);
live = result.games;
console.log(`[kbo-merge] fetched live count=${live.length}`);
} catch (e) {
// 실패를 호출자에게 전파하지 않는다 — 일정 화면이 깨지는 것보다 점수 미반영이 낫다.
console.log(`[kbo-merge] fetch failed`, e);
return games;
}
// 매칭 효율을 위한 두 가지 인덱스 구성:
// - byId: gameId 기반 정확 매칭 (1차 키)
// - byComposite: (away|home|time) 합성 키 기반 폴백 매칭 (2차 키)
const byId = new Map<string, GameListRecord>();
const byComposite = new Map<string, GameListRecord>();
for (const rec of live) {
// gameId가 누락된 레코드는 byId에 넣지 않아 잘못된 매칭을 예방한다.
if (rec.gameId) byId.set(rec.gameId, rec);
// 합성 키는 항상 채워 폴백 경로를 보장한다.
byComposite.set(
liveKey(rec.awayTeamCode, rec.homeTeamCode, rec.time),
rec
);
}
// 매칭 성공 건수 — 디버깅용 로그에서만 사용.
let matched = 0;
const result = games.map((g) => {
// 1) gameId 우선 매칭, 2) 실패 시 합성 키로 폴백.
const rec =
(g.gameId && byId.get(g.gameId)) ||
byComposite.get(liveKey(g.awayTeamCode, g.homeTeamCode, g.time));
// 실시간 레코드를 찾지 못하면 원본 일정 객체를 그대로 반환한다.
if (!rec) {
console.log(`[kbo-merge] miss gameId=${g.gameId} key=${liveKey(g.awayTeamCode, g.homeTeamCode, g.time)}`);
return g;
}
matched++;
// 원본을 직접 변경하지 않기 위해 얕은 복사 후 갱신한다.
const merged: ScheduleGame = { ...g };
// 점수는 null이 아닐 때만 덮어쓴다 — 미시작 경기에서 0으로 잘못 표시되는 것을 방지.
if (rec.score.away != null) merged.awayScore = rec.score.away;
if (rec.score.home != null) merged.homeScore = rec.score.home;
// 상태 코드 → 도메인 상태 변환. 변환 결과가 없으면(미정의 코드) 기존 상태를 유지한다.
const nextStatus = statusFromRecord(rec);
if (nextStatus) merged.status = nextStatus;
console.log(`[kbo-merge] hit gameId=${g.gameId} state=${rec.status.stateCode} score=${merged.awayScore}:${merged.homeScore} status=${merged.status}`);
return merged;
});
console.log(`[kbo-merge] done matched=${matched}/${games.length}`);
return result;
}

View File

@ -8,7 +8,7 @@ import type {
TeamCode,
User,
} from "../types/panit";
import type { DateString } from "../types/dateString";
import { type DateString } from "../types/dateString";
const COLLECTION = "users";
@ -24,6 +24,10 @@ export interface RegisterInput {
/**
* .
*
* 주의: 반환된 `currentStreak` lazy .
* streak `statsService.getStats` .
* `applyDailyJudgmentTx` .
*
* @param uid - Firebase Auth UID
* @returns . `null`.
*/
@ -182,6 +186,11 @@ export async function applyDailyJudgmentTx(
judgment: DailyJudgment;
correctCount: number;
completedCount: number;
/**
* (judgeDay) . true면 `currentStreak` 0
* . .
*/
streakBrokenIn: boolean;
computePoints: (streakAfter: number) => number;
}
): Promise<DailyJudgmentResult> {
@ -202,7 +211,8 @@ export async function applyDailyJudgmentTx(
};
}
const currentStreak = user.currentStreak ?? 0;
// 결석 여부는 호출자(judgeDay)가 휴장일을 제외하고 사전에 판정해서 넘겨준다.
const currentStreak = input.streakBrokenIn ? 0 : user.currentStreak ?? 0;
const highestStreak = user.highestStreak ?? 0;
const tierPoints = user.tierPoints ?? 0;
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;

View File

@ -75,16 +75,22 @@ async function reconcileDayVotes(
return result;
}
export const dailyArchive = onSchedule(
{ schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
async () => {
const date = daysAgoKst(1);
/**
* dailyArchive의 . (`runDailyArchiveNow`) .
*
* @param overrideDate archive . (KST).
* @returns
*/
export async function runDailyArchive(
overrideDate?: DateString
): Promise<{ date: DateString; archived: number; judgedUids: string[] }> {
const date = overrideDate ?? daysAgoKst(1);
logger.info(`dailyArchive start: ${date}`);
const snap = await rtdb.ref("/userVotes").get();
if (!snap.exists()) {
logger.info("no userVotes to archive");
return;
return { date, archived: 0, judgedUids: [] };
}
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
@ -153,5 +159,12 @@ export const dailyArchive = onSchedule(
}
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
return { date, archived, judgedUids };
}
export const dailyArchive = onSchedule(
{ schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
async () => {
await runDailyArchive();
}
);

View File

@ -5,7 +5,11 @@ import {
fetchRankFromKbo,
fetchScheduleFromKbo,
} from "../repositories/kboRepository.js";
import { syncGamesForMonth } from "../services/gameSyncService.js";
import {
syncGamesForMonth,
syncYesterdayFromLive,
} from "../services/gameSyncService.js";
import { daysAgoKst } from "../types/dateString.js";
const CACHE_COLLECTION = "kboCache";
@ -50,6 +54,16 @@ export const kboDailyRefresh = onSchedule(
logger.info(`synced ${nextCount} games for ${ny}-${nm}`);
}
// 어제 경기 status 확정: 월간 schedule refresh가 월 마지막 날을 놓치는 케이스를
// 라이브 데이터(`getGameList`)로 보완. 휴장일이면 0건 반환되어 무해.
try {
const yesterdayYmd = daysAgoKst(1).replace(/-/g, "");
const { updated } = await syncYesterdayFromLive(yesterdayYmd);
logger.info(`syncYesterdayFromLive: ${updated} games updated for ${yesterdayYmd}`);
} catch (err) {
logger.error("syncYesterdayFromLive failed", err);
}
logger.info("KBO refresh complete");
} catch (err) {
logger.error("KBO refresh failed", err);

View File

@ -1,7 +1,12 @@
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase.js";
import { fetchScheduleFromKbo } from "../repositories/kboRepository.js";
import type { ScheduleGame } from "../kbo/schedule.js";
import {
fetchScheduleFromKbo,
statusFromRecord,
} from "../repositories/kboRepository.js";
import { getGameList } from "./gameListService.js";
import type { GameListRecord } from "../kbo/game-list.js";
import type { ScheduleGame, GameStatus } from "../kbo/schedule.js";
import type { Game } from "../types/panit.js";
const COLLECTION = "games";
@ -67,3 +72,78 @@ export async function syncGamesForMonth(year: number, month: number): Promise<nu
if (count > 0) await batch.commit();
return count;
}
/**
* (`getGameList` ) `games` .
* status가 (`null`) `null` .
*
* `completed` `winningTeamCode` .
* status가 `completed` `winningTeamCode` ( ).
*/
export function gameUpdateFromRecord(
rec: GameListRecord
): { status: GameStatus; winningTeamCode?: string } | null {
const status = statusFromRecord(rec);
if (!status) return null;
const update: { status: GameStatus; winningTeamCode?: string } = { status };
if (
status === "completed" &&
rec.score.home != null &&
rec.score.away != null &&
rec.score.home !== rec.score.away
) {
update.winningTeamCode =
rec.score.home > rec.score.away ? rec.homeTeamCode : rec.awayTeamCode;
}
return update;
}
/**
* (YYYYMMDD) `games` status·winningTeamCode를
* . read로 ** ** write한다
* write를 . status `onGameCompleted`
* (userVotes에 result ) .
*
* schedule refresh가 "월의 마지막 날" . 0 .
*
* @param yyyymmdd KST YYYYMMDD
* @returns `updated`: write가
*/
export async function syncYesterdayFromLive(
yyyymmdd: string
): Promise<{ updated: number }> {
const result = await getGameList(yyyymmdd);
const targets: Array<{
gameId: string;
update: { status: GameStatus; winningTeamCode?: string };
}> = [];
for (const rec of result.games) {
if (!rec.gameId) continue;
const update = gameUpdateFromRecord(rec);
if (!update) continue;
targets.push({ gameId: rec.gameId, update });
}
if (targets.length === 0) return { updated: 0 };
const refs = targets.map((t) => firestore.collection(COLLECTION).doc(t.gameId));
const snaps = await firestore.getAll(...refs);
const batch = firestore.batch();
let updated = 0;
snaps.forEach((snap, i) => {
const { gameId, update } = targets[i];
const existing = snap.exists ? (snap.data() as Partial<Game>) : null;
const sameStatus = existing?.status === update.status;
const sameWinner = existing?.winningTeamCode === update.winningTeamCode;
if (existing && sameStatus && sameWinner) return;
batch.set(firestore.collection(COLLECTION).doc(gameId), update, {
merge: true,
});
updated++;
});
if (updated > 0) await batch.commit();
return { updated };
}

View File

@ -4,6 +4,7 @@ import { getRange, setDay } from "../repositories/voteHistoryRepository.js";
import {
applyDailyJudgmentTx,
applyWeeklyMasterTx,
getUser,
} from "../repositories/userRepository.js";
import {
judgeByCounts,
@ -13,6 +14,32 @@ import {
import type { DailyJudgment, VoteHistoryDoc } from "../types/panit.js";
import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString.js";
/**
* `(lastJudged, upTo)` ( ) ** **(=`thresholdsFor`
* `"skip"` ) .
* streak이 . skip이거나 .
*
* KBO (, , ) .
*
* 비용: 구간 `games` read. 14
* ( 14 ).
*/
export async function hasMissedGameDayBetween(
lastJudged: DateString,
upTo: DateString
): Promise<boolean> {
const MAX_LOOKBACK = 14;
let cursor = addDaysKst(upTo, -1);
for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) {
const games = await listByDate(cursor);
const completed = games.filter((g) => g.status === "completed").length;
if (thresholdsFor(completed) !== "skip") return true;
cursor = addDaysKst(cursor, -1);
}
// MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다.
return cursor > lastJudged;
}
/**
* voteHistory와 user doc에 .
*
@ -42,10 +69,20 @@ export async function judgeDay(
judgment = judgeByCounts(correctCount, threshold);
}
// 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제
// 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김.
const userPre = await getUser(uid);
const lastJudgedPre = userPre?.lastJudgedDate;
const streakBrokenIn =
lastJudgedPre != null &&
lastJudgedPre < addDaysKst(date, -1) &&
(await hasMissedGameDayBetween(lastJudgedPre, date));
const tx = await applyDailyJudgmentTx(uid, date, {
judgment,
correctCount,
completedCount,
streakBrokenIn,
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
});

View File

@ -4,8 +4,10 @@ import {computeLevel} from "../constants/levels.js";
import {tierOf} from "../constants/tiers.js";
import {getAll, getDay} from "../repositories/voteHistoryRepository.js";
import {getUser} from "../repositories/userRepository.js";
import {hasMissedGameDayBetween} from "./judgmentService.js";
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit.js";
import {
addDaysKst,
parseDateString,
toDateString,
startOfDayKst,
@ -184,7 +186,23 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
const periodAgg = aggregate(periodEntries);
const storedStreak = user?.currentStreak;
// 결석으로 streak이 끊겼는지 lazy 보정.
// 1) `lastJudgedDate`가 어제 이후면 정상.
// 2) archive 대기 윈도우 보호: 어제분 `userVotes`가 아직 RTDB에 남아 있으면 archive가
// 안 돌았을 뿐이지 활성 유저이므로 결석 아님.
// 3) `(lastJudged, today)` 구간에 실제 경기일이 한 번이라도 있었으면 그날 결석한 것이므로 끊김.
// KBO 월요일/올스타브레이크 등 휴장일은 자동 무시(`thresholdsFor === "skip"`).
// user doc은 갱신하지 않는다 — 다음 판정 시 `applyDailyJudgmentTx`가 정리한다.
const lastJudged = user?.lastJudgedDate;
const yesterday = addDaysKst(today, -1);
let streakBroken = false;
if (lastJudged != null && lastJudged < yesterday) {
const pending = await rtdb.ref(`/userVotes/${uid}/${yesterday}`).get();
if (!pending.exists()) {
streakBroken = await hasMissedGameDayBetween(lastJudged, today);
}
}
const storedStreak = streakBroken ? 0 : user?.currentStreak;
const streakDays = storedStreak ?? computeStreak(all);
const highestStreak = user?.highestStreak ?? streakDays;
const tierPoints = user?.tierPoints ?? 0;
@ -211,6 +229,7 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
tierPoints,
tickets,
updatedAt: Date.now(),
forDate: today,
};
}
@ -230,8 +249,14 @@ export async function getStats(uid: string, periodParam?: string): Promise<Stats
const period = parsePeriod(periodParam);
const key = periodParam && periodParam !== "current" ? periodParam : "current";
// 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다.
// 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다.
const today = todayKst();
const cached = await rtdb.ref(cachePath(uid, key)).get();
if (cached.exists()) return cached.val() as StatsResponse;
if (cached.exists()) {
const val = cached.val() as StatsResponse;
if (val.forDate === today) return val;
}
const stats = await computeStats(uid, period);
await rtdb.ref(cachePath(uid, key)).set(stats);

View File

@ -40,11 +40,31 @@ export interface User {
favoriteTeamCode?: TeamCode;
knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp;
/**
* (`applyDailyJudgmentTx`) .
*
* 주의: lazy . streak이
* .
*
* ,
* `getStats`(statsService) `streakDays` .
*
* `applyDailyJudgmentTx` .
*/
currentStreak?: number;
highestStreak?: number;
tierPoints?: number;
tickets?: TicketMap;
/**
* KST .
* `currentStreak` ,
* (`hasMissedGameDayBetween`) .
*/
lastJudgedDate?: DateString;
lastWeeklyMasterTuesday?: DateString;
rankSnapshot?: RankSnapshot;
}
@ -101,4 +121,5 @@ export interface StatsResponse {
tierPoints: number;
tickets: TicketMap;
updatedAt: number;
forDate: DateString;
}

View File

@ -4,8 +4,10 @@ import {
toGameDoc,
toTime,
syncGamesForMonth,
gameUpdateFromRecord,
} from "../../src/services/gameSyncService.js";
import type { ScheduleGame } from "../../src/kbo/schedule.js";
import type { GameListRecord } from "../../src/kbo/game-list.js";
function baseGame(overrides: Partial<ScheduleGame> = {}): ScheduleGame {
return {
@ -75,6 +77,102 @@ describe("gameSyncService.toGameDoc", () => {
});
});
function baseLiveRecord(overrides: Partial<GameListRecord> = {}): GameListRecord {
return {
gameId: "20260430HTLG0",
date: "20260430",
time: "18:30",
season: 2026,
stadium: "잠실",
homeTeamCode: "LG",
awayTeamCode: "HT",
homeTeamName: "LG",
awayTeamName: "KIA",
homeRank: null,
awayRank: null,
broadcast: "",
status: {
stateCode: "1",
cancelCode: "0",
cancelName: "",
inning: null,
topBottom: null,
},
score: { home: null, away: null },
count: { ball: null, strike: null, out: null },
runners: { first: null, second: null, third: null },
currentBatter: null,
currentPitcher: null,
startingPitchers: { away: null, home: null },
decisions: { winner: null, loser: null, save: null },
...overrides,
} as GameListRecord;
}
describe("gameSyncService.gameUpdateFromRecord", () => {
it("stateCode=1 (예정)은 status=scheduled, winningTeamCode 없음", () => {
const u = gameUpdateFromRecord(baseLiveRecord());
expect(u).toEqual({ status: "scheduled" });
});
it("stateCode=2 (진행 중)은 status=live", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "2", cancelCode: "0", cancelName: "", inning: 5, topBottom: "T" },
})
);
expect(u).toEqual({ status: "live" });
});
it("stateCode=3 + 홈 승리 점수면 status=completed + winningTeamCode=홈", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null },
score: { home: 7, away: 2 },
})
);
expect(u).toEqual({ status: "completed", winningTeamCode: "LG" });
});
it("stateCode=3 + 원정 승리면 winningTeamCode=원정", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null },
score: { home: 1, away: 5 },
})
);
expect(u).toEqual({ status: "completed", winningTeamCode: "HT" });
});
it("stateCode=3 + 무승부면 winningTeamCode 없음", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null },
score: { home: 3, away: 3 },
})
);
expect(u).toEqual({ status: "completed" });
});
it("cancelCode != '0' 이면 stateCode 무관하게 status=cancelled", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "1", cancelCode: "1", cancelName: "우천", inning: null, topBottom: null },
})
);
expect(u).toEqual({ status: "cancelled" });
});
it("알 수 없는 stateCode면 null 반환", () => {
const u = gameUpdateFromRecord(
baseLiveRecord({
status: { stateCode: "9", cancelCode: "0", cancelName: "", inning: null, topBottom: null },
})
);
expect(u).toBeNull();
});
});
describe("gameSyncService.syncGamesForMonth (실제 KBO 호출)", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("games"));

View File

@ -262,6 +262,115 @@ describe("judgmentService (Firestore emulator)", () => {
});
});
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, [

View File

@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { firestore, rtdb } from "../../src/firebase.js";
import { getStats } from "../../src/services/statsService.js";
import {
addDaysKst,
todayKst,
type DateString,
} from "../../src/types/dateString.js";
import type {
Game,
GameStatus,
StatsResponse,
User,
} from "../../src/types/panit.js";
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 기준으로 재계산
});
});