mmday-firebase/tests/services/gameDetailService.test.ts
윤정민 5888d446b7 Implement KBO game detail retrieval with dynamic caching and API.
- KBO 게임센터의 스코어보드, 박스스코어, 키플레이어 데이터를 통합 조회하는 기능을 구현했습니다.
- 경기 상태(종료, 진행 중, 시작 전)에 따라 TTL을 30초에서 7일까지 유연하게 적용하는 동적 캐싱 시스템을 도입했습니다.
- KBO 공식 데이터에서 이닝별 득점이 누락되는 경우를 대비해 네이버 스포츠 API를 통한 데이터 보완 로직을 추가했습니다.
- Firestore의 중첩 배열 저장 제한을 회피하기 위해 테이블 데이터를 객체 배열 구조로 파싱하도록 설계했습니다.
- CLI 상세 조회 명령어와 REST API 엔드포인트를 추가하고, HTML 엔티티 디코딩 및 관련 단위 테스트를 보완했습니다.
2026-04-30 16:34:09 +09:00

98 lines
4.5 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { firestore } from "../../src/firebase.js";
import { getGameDetail } from "../../src/services/gameDetailService.js";
// 실제 KBO 게임센터 + Firestore 에뮬레이터를 함께 사용한다.
// nested-array Firestore 거부, parseGameStartTs NPE 등 회귀를 잡기 위함.
//
// 기준 경기: 2025-09-20 SS@LG (정규시즌 종료 경기, 12회 연장).
const ENDED_GAME_ID = "20250920SSLG0";
describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("kboCache"));
await firestore.recursiveDelete(firestore.collection("kboLocks"));
});
it("종료 경기를 fetch하고 Firestore 캐시에 정상 저장한다", async () => {
const t0 = Date.now();
const result = await getGameDetail({ gameId: ENDED_GAME_ID });
console.log(`[detail] fetch 완료 (${Date.now() - t0}ms)`);
expect(result.gameId).toBe(ENDED_GAME_ID);
expect(result.scoreBoard.meta.gameId).toBe(ENDED_GAME_ID);
expect(result.scoreBoard.meta.endTime).toBeTruthy();
expect(result.scoreBoard.meta.awayTeam.id).toBe("SS");
expect(result.scoreBoard.meta.homeTeam.id).toBe("LG");
expect(result.scoreBoard.innings.length).toBeGreaterThan(0);
expect(result.scoreBoard.totals.away.runs).toBeTypeOf("number");
expect(result.boxScore.events.length).toBeGreaterThan(0);
expect(result.boxScore.away.hitters.length).toBeGreaterThan(0);
expect(result.boxScore.away.pitchers.length).toBeGreaterThan(0);
expect(result.keyPlayer.hitter.items.length).toBeGreaterThan(0);
expect(result.keyPlayer.pitcher.items.length).toBeGreaterThan(0);
// 도메인 타입 — 첫 타자: 순번/위치/이름 + 이닝 결과 길이가 maxInning과 일치
const firstHitter = result.boxScore.away.hitters[0];
expect(firstHitter.name).toBeTruthy();
expect(firstHitter.order).toBeTruthy();
expect(firstHitter.inningResults.length).toBe(result.scoreBoard.maxInning);
// 빈 이닝(타석에 안 들어선 칸)은 "" — KBO의  가 디코딩되어 사라져야 함
for (const cell of firstHitter.inningResults) {
expect(cell).not.toMatch(/ /);
}
expect(firstHitter.ab).toBeTypeOf("number");
expect(firstHitter.seasonAvg).toBeTypeOf("number");
// 첫 투수: 시즌 W/L/S + 이닝/실점/자책 모두 채워짐
const firstPitcher = result.boxScore.away.pitchers[0];
expect(firstPitcher.name).toBeTruthy();
expect(firstPitcher.innings).toBeTruthy();
expect(firstPitcher.earnedRuns).toBeTypeOf("number");
expect(firstPitcher.seasonEra).toBeTypeOf("number");
// ParsedTableRow 형태 확인 (Firestore가 받을 수 있는 모양: 배열 안 객체)
const sampleRawRow = result.boxScore.away.raw.hitterByInning.rows[0];
expect(sampleRawRow).toHaveProperty("cells");
expect(Array.isArray(sampleRawRow.cells)).toBe(true);
// Firestore에 캐시 doc이 실제로 써졌는지 확인 (nested-array 회귀 방지)
const snap = await firestore
.collection("kboCache")
.doc(`game_detail__${ENDED_GAME_ID}`)
.get();
expect(snap.exists).toBe(true);
const doc = snap.data() as { ttlMs?: number };
// 종료 경기는 7일 TTL
expect(doc.ttlMs).toBe(7 * 24 * 60 * 60 * 1000);
}, 30000);
it("두 번째 호출은 캐시에서 온다 (외부 호출 0회)", async () => {
const start1 = Date.now();
await getGameDetail({ gameId: ENDED_GAME_ID });
const firstDuration = Date.now() - start1;
const start2 = Date.now();
const cached = await getGameDetail({ gameId: ENDED_GAME_ID });
const secondDuration = Date.now() - start2;
console.log(`[cache] ${firstDuration}ms → ${secondDuration}ms`);
expect(cached.gameId).toBe(ENDED_GAME_ID);
expect(secondDuration).toBeLessThan(firstDuration);
}, 30000);
it("잘못된 gameId 형식은 거부한다", async () => {
await expect(getGameDetail({ gameId: "bogus" })).rejects.toThrow(
/Invalid gameId/
);
});
it("series가 잘못되어 빈 응답이 와도 NPE 대신 명확한 에러를 던진다", async () => {
// 정규시즌 경기를 포스트시즌 srId(3)으로 조회 → KBO가 빈 응답 반환.
// parseGameStartTs/엔티티 접근에서 죽지 않고 "Game not found"로 던져야 함.
await expect(
getGameDetail({ gameId: ENDED_GAME_ID, series: "포스트" })
).rejects.toThrow(/Game not found|unavailable/i);
}, 15000);
});