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); });