Implement lazy streak correction and enhance game result synchronization.
- 사용자의 마지막 참여일 이후 실제 경기가 있었음에도 참여하지 않은 경우를 감지하여 스트릭을 0으로 초기화하는 지연 보정(lazy correction) 로직을 도입했습니다. - KBO 휴장일(월요일, 전체 우천 취소 등)을 자동으로 식별하여 실제 경기가 열린 날에만 결석 판정이 내려지도록 `hasMissedGameDayBetween` 검증 기능을 구현했습니다. - 실시간 라이브 데이터를 기반으로 전일 경기 결과를 확정하는 기능을 추가하여 월간 일정 갱신 과정에서 발생할 수 있는 데이터 누락을 보완했습니다. - 일일 아카이브 및 경기 동기화 로직을 수동으로 트리거할 수 있는 디버그 엔드포인트를 추가하고, 통계 데이터에 산출 기준일(forDate)을 포함하여 캐시 무효화 정밀도를 개선했습니다.
This commit is contained in:
parent
5149ecf82a
commit
1a68d41e4f
@ -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": []
|
||||
|
||||
54
src/handlers/debugHandlers.ts
Normal file
54
src/handlers/debugHandlers.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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, [
|
||||
|
||||
235
tests/services/statsService.test.ts
Normal file
235
tests/services/statsService.test.ts
Normal 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 기준으로 재계산
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user