mmday-firebase/tests/services/gameDetailService.test.ts
윤정민 eb17177f77 Add starting lineup support and enhance pre-game data handling.
- KBO 경기 상세 정보에 선발 및 예상 라인업 정보를 포함하고 CLI에서 이를 확인할 수 있는 출력 기능을 추가했습니다.
- 경기가 시작되기 전 KBO API가 빈 데이터를 반환하는 상황(code 200)을 예외 처리하고, 경기 상태에 따라 null을 반환하도록 개선했습니다.
- 종료된 경기는 박스스코어에서 선발 명단을 추출하고, 시작 전 경기는 라인업 분석 API를 호출하는 이원화된 데이터 수집 로직을 구현했습니다.
- 예상 라인업이 확정으로 전환되는 시점을 빠르게 반영하기 위해 라인업 출처에 따라 캐시 TTL을 동적으로 조절하는 기능을 도입했습니다.
- 라인업 파싱 및 경기 시작 전후의 데이터 처리 로직에 대한 검증을 위해 서비스 레이어 테스트를 업데이트했습니다.
2026-05-18 13:07:02 +09:00

121 lines
5.4 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { firestore } from "../../src/firebase";
import { getGameDetail } from "../../src/services/gameDetailService";
// 실제 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).not.toBeNull();
expect(result.boxScore).not.toBeNull();
const scoreBoard = result.scoreBoard!;
const boxScore = result.boxScore!;
expect(scoreBoard.meta.gameId).toBe(ENDED_GAME_ID);
expect(scoreBoard.meta.endTime).toBeTruthy();
expect(scoreBoard.meta.awayTeam.id).toBe("SS");
expect(scoreBoard.meta.homeTeam.id).toBe("LG");
expect(scoreBoard.innings.length).toBeGreaterThan(0);
expect(scoreBoard.totals.away.runs).toBeTypeOf("number");
expect(boxScore.events.length).toBeGreaterThan(0);
expect(boxScore.away.hitters.length).toBeGreaterThan(0);
expect(boxScore.away.pitchers.length).toBeGreaterThan(0);
expect(result.keyPlayer.hitter).not.toBeNull();
expect(result.keyPlayer.pitcher).not.toBeNull();
expect(result.keyPlayer.hitter!.items.length).toBeGreaterThan(0);
expect(result.keyPlayer.pitcher!.items.length).toBeGreaterThan(0);
// 도메인 타입 — 첫 타자: 순번/위치/이름 + 이닝 결과 길이가 maxInning과 일치
const firstHitter = boxScore.away.hitters[0];
expect(firstHitter.name).toBeTruthy();
expect(firstHitter.order).toBeTruthy();
expect(firstHitter.inningResults.length).toBe(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 = 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 = boxScore.away.raw.hitterByInning.rows[0];
expect(sampleRawRow).toHaveProperty("cells");
expect(Array.isArray(sampleRawRow.cells)).toBe(true);
// 종료 경기 라인업: BoxScore에서 추출됨 (사실 우선 원칙)
expect(result.lineup).not.toBeNull();
const lineup = result.lineup!;
expect(lineup.source).toBe("boxscore");
expect(lineup.announced).toBe(true);
expect(lineup.home.slots.length).toBe(9);
expect(lineup.away.slots.length).toBe(9);
expect(lineup.home.teamId).toBe("LG");
expect(lineup.away.teamId).toBe("SS");
// 9명이 1~9번 타순 1회씩 채워짐
const homeOrders = lineup.home.slots.map((s) => s.batOrder).sort();
expect(homeOrders).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
// 약어 → 풀네임 확장 (BoxScore "유" 등이 "유격수"로)
for (const s of lineup.home.slots) {
expect(s.position.length).toBeGreaterThan(1);
}
// 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);
});