- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
98 lines
4.5 KiB
TypeScript
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);
|
|
});
|