Clamp future schedule cache TTL to the target day

- 모레 이후 일자의 TTL 이 7일 고정이라, 2~7일 앞서 캐시된 날짜가 경기 당일과
  그 이후까지 "경기 전" 스냅샷을 계속 내놓았다
- TTL 은 쓰는 시점의 미래 거리로만 정해지고 시간이 흘러도 재평가되지 않으므로,
  해당 날짜 00:00 을 넘지 않도록 잘라 경기 당일에는 반드시 다시 받아오게 한다.
  이후로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL 을 다시 매긴다
- 실제 사고: 07-27 에 담긴 08-01 스냅샷이 7일 TTL 로 08-03 까지 살아남아,
  폭염취소된 NC 경기가 달력에서 계속 scheduled 로 보였다
- dayTtlMs 를 export 하고 fake timer 기반 회귀 테스트 8건 추가
- 함수 주석의 TTL 표를 실제 동작대로 다시 적었다 (기존 "본 모듈 상단의 표
  참조" 는 존재하지 않는 표를 가리키고 있었다)
This commit is contained in:
윤정민 2026-08-03 14:54:03 +09:00
parent 90d6895de4
commit 89e817206b
2 changed files with 115 additions and 3 deletions

View File

@ -122,9 +122,20 @@ function parseHHMM(t: string): number | null {
} }
/** /**
* TTL . . * TTL .
*
* 과거 : / 7d, 30s
* 오늘 : 시작 ( 1h), 30s, 7d
* 내일 : 6h
* 이후: 7d
*
* . TTL은 '쓰는 시점의 미래 거리'
* , 2~7
* "경기 전" .
* ( 사고: 07-27 08-01 7d TTL로 08-03 ,
* `scheduled` .)
*/ */
function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { export function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
const y = parseInt(yyyymmdd.slice(0, 4), 10); const y = parseInt(yyyymmdd.slice(0, 4), 10);
const m = parseInt(yyyymmdd.slice(4, 6), 10); const m = parseInt(yyyymmdd.slice(4, 6), 10);
const d = parseInt(yyyymmdd.slice(6, 8), 10); const d = parseInt(yyyymmdd.slice(6, 8), 10);
@ -148,7 +159,12 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS; return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS;
} }
if (diffDays === 1) return SIX_HOURS_MS; if (diffDays === 1) return SIX_HOURS_MS;
if (diffDays >= 2) return SEVEN_DAYS_MS; if (diffDays >= 2) {
// 해당 날짜 00:00 에 만료시켜, 경기 당일에는 반드시 다시 받아오게 한다.
// 그 뒤로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL을 다시 매긴다.
const untilDayStartMs = dayStart - now.getTime();
return Math.min(SEVEN_DAYS_MS, Math.max(untilDayStartMs, THIRTY_SEC_MS));
}
// 오늘 // 오늘
if (games.length === 0) return SIX_HOURS_MS; if (games.length === 0) return SIX_HOURS_MS;

View File

@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dayTtlMs } from "../../src/repositories/kboRepository";
import type { ScheduleGame, GameStatus } from "../../src/kbo/schedule";
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
const THIRTY_SEC_MS = 30_000;
function game(status: GameStatus, time = "18:00"): ScheduleGame {
return { status, time } as unknown as ScheduleGame;
}
/** yyyymmdd 문자열의 로컬 자정 타임스탬프. dayTtlMs 와 같은 기준. */
function localDayStart(yyyymmdd: string): number {
const y = Number(yyyymmdd.slice(0, 4));
const m = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
return new Date(y, m - 1, d).getTime();
}
describe("dayTtlMs", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("모레 이후", () => {
it("TTL이 해당 날짜 자정을 넘기지 않는다", () => {
// 회귀 테스트: 2026-07-27 에 담긴 08-01 스냅샷이 7d TTL로 08-03까지
// 살아남아, 폭염취소된 경기가 달력에서 계속 `scheduled` 로 보였다.
vi.setSystemTime(new Date(2026, 6, 27, 21, 6));
const ttl = dayTtlMs("20260801", [game("scheduled")]);
expect(Date.now() + ttl).toBe(localDayStart("20260801"));
expect(ttl).toBeLessThan(SEVEN_DAYS_MS);
});
it("7일보다 먼 날짜는 7d 로 상한을 둔다", () => {
vi.setSystemTime(new Date(2026, 6, 27, 21, 6));
expect(dayTtlMs("20260901", [game("scheduled")])).toBe(SEVEN_DAYS_MS);
});
it("자정 직전이어도 최소 30s 는 보장한다", () => {
// 2026-08-01 23:59:59 → 08-03 자정까지 남은 시간은 0 에 가깝지 않지만,
// 경계에서 0/음수 TTL 이 나오지 않는지 하한을 확인한다.
vi.setSystemTime(new Date(2026, 7, 1, 23, 59, 59, 900));
expect(dayTtlMs("20260803", [game("scheduled")]))
.toBeGreaterThanOrEqual(THIRTY_SEC_MS);
});
});
it("내일은 6h", () => {
vi.setSystemTime(new Date(2026, 7, 1, 10, 0));
expect(dayTtlMs("20260802", [game("scheduled")])).toBe(SIX_HOURS_MS);
});
describe("지난 날짜", () => {
it("모두 종료/취소면 7d", () => {
vi.setSystemTime(new Date(2026, 7, 3, 10, 0));
const games = [game("completed"), game("cancelled")];
expect(dayTtlMs("20260801", games)).toBe(SEVEN_DAYS_MS);
});
it("미종료 경기가 남아 있으면 30s", () => {
vi.setSystemTime(new Date(2026, 7, 3, 10, 0));
const games = [game("completed"), game("scheduled")];
expect(dayTtlMs("20260801", games)).toBe(THIRTY_SEC_MS);
});
});
describe("오늘", () => {
it("첫 경기 시작 전이면 시작까지만 캐시한다", () => {
vi.setSystemTime(new Date(2026, 7, 3, 17, 30));
// 18:00 시작 → 30분
expect(dayTtlMs("20260803", [game("scheduled", "18:00")]))
.toBe(30 * 60_000);
});
it("모두 끝났으면 7d", () => {
vi.setSystemTime(new Date(2026, 7, 3, 23, 0));
expect(dayTtlMs("20260803", [game("completed")])).toBe(SEVEN_DAYS_MS);
});
});
});