diff --git a/firestore.indexes.json b/firestore.indexes.json index 531658f..4beb109 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -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": [] diff --git a/src/handlers/debugHandlers.ts b/src/handlers/debugHandlers.ts new file mode 100644 index 0000000..da6e188 --- /dev/null +++ b/src/handlers/debugHandlers.ts @@ -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); + } +}); diff --git a/src/index.ts b/src/index.ts index 96bfb79..a8ba253 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 6a53bc8..9d8b8e4 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -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 { + // 서버 로컬 타임존 기준 "오늘" 날짜. 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(); const byComposite = new Map(); 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; } diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 0f409e6..231a6bb 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -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 { @@ -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; diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 3d4dad5..a69f412 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -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>; @@ -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(); } ); diff --git a/src/scheduled/kboRefresh.ts b/src/scheduled/kboRefresh.ts index dedb3b3..a439e8a 100644 --- a/src/scheduled/kboRefresh.ts +++ b/src/scheduled/kboRefresh.ts @@ -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); diff --git a/src/services/gameSyncService.ts b/src/services/gameSyncService.ts index 5676380..6d14372 100644 --- a/src/services/gameSyncService.ts +++ b/src/services/gameSyncService.ts @@ -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 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) : 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 }; +} diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index 4371a91..f11b399 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -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 { + 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), }); diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 4a23aa1..6956d52 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -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 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 tierPoints, tickets, updatedAt: Date.now(), + forDate: today, }; } @@ -230,8 +249,14 @@ export async function getStats(uid: string, periodParam?: string): Promise = {}): ScheduleGame { return { @@ -75,6 +77,102 @@ describe("gameSyncService.toGameDoc", () => { }); }); +function baseLiveRecord(overrides: Partial = {}): 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")); diff --git a/tests/services/judgmentService.test.ts b/tests/services/judgmentService.test.ts index b3b47b2..8b28d01 100644 --- a/tests/services/judgmentService.test.ts +++ b/tests/services/judgmentService.test.ts @@ -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, [ diff --git a/tests/services/statsService.test.ts b/tests/services/statsService.test.ts new file mode 100644 index 0000000..1c583b1 --- /dev/null +++ b/tests/services/statsService.test.ts @@ -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 { + 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 = {}): Promise { + 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 +): Promise { + 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 기준으로 재계산 + }); +});