mmday-firebase/tests/services/gameDetailService.test.ts
윤정민 c94ce69e4f Remove redundant .js extensions from import paths.
- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다.
- 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
2026-05-06 16:17:51 +09:00

98 lines
4.5 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.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);
});