import { beforeEach, describe, expect, it } from "vitest"; import { firestore } from "../../src/firebase"; import { toGameDoc, toTime, syncGamesForMonth, gameUpdateFromRecord, } from "../../src/services/gameSyncService"; import type { ScheduleGame } from "../../src/kbo/schedule"; import type { GameListRecord } from "../../src/kbo/game-list"; function baseGame(overrides: Partial = {}): ScheduleGame { return { date: "04.12", dayOfWeek: "일", time: "18:30", awayTeamCode: "HT", homeTeamCode: "LG", awayScore: null, homeScore: null, status: "scheduled", stadium: "잠실", broadcast: "KN-T", note: "-", gameId: "20260412HTLG0", awayStartingPitcher: null, homeStartingPitcher: null, ...overrides, }; } describe("gameSyncService.toTime", () => { it("KST 18:30은 UTC 09:30으로 변환된다", () => { const ts = toTime(2026, "04.12", "18:30"); const d = ts.toDate(); expect(d.toISOString()).toBe("2026-04-12T09:30:00.000Z"); }); it("시간이 비어있으면 00:00으로 처리한다", () => { const ts = toTime(2026, "04.12", ""); expect(ts.toDate().toISOString()).toBe("2026-04-11T15:00:00.000Z"); }); }); describe("gameSyncService.toGameDoc", () => { it("scheduled 경기는 status=scheduled, 승리팀 없음", () => { const doc = toGameDoc(2026, baseGame()); expect(doc).not.toBeNull(); expect(doc!.status).toBe("scheduled"); expect(doc!.homeTeamCode).toBe("LG"); expect(doc!.winningTeamCode).toBeUndefined(); }); it("completed 경기는 status=completed, 점수 기반 winningTeamCode 설정", () => { const doc = toGameDoc( 2026, baseGame({ status: "completed", homeScore: 7, awayScore: 2 }) ); expect(doc!.status).toBe("completed"); expect(doc!.winningTeamCode).toBe("LG"); }); it("cancelled 경기는 status=cancelled", () => { const doc = toGameDoc(2026, baseGame({ status: "cancelled" })); expect(doc!.status).toBe("cancelled"); }); it("cancelled + note가 '-' 이외면 cancelReason에 사유 저장", () => { const doc = toGameDoc( 2026, baseGame({ status: "cancelled", note: "우천취소" }) ); expect(doc!.cancelReason).toBe("우천취소"); }); it("cancelled여도 note가 '-'이면 cancelReason 미설정", () => { const doc = toGameDoc(2026, baseGame({ status: "cancelled", note: "-" })); expect(doc!.cancelReason).toBeUndefined(); }); it("무승부는 winningTeamCode가 없다", () => { const doc = toGameDoc( 2026, baseGame({ status: "completed", homeScore: 3, awayScore: 3 }) ); expect(doc!.winningTeamCode).toBeUndefined(); }); it("gameId가 null이면 null 반환", () => { const doc = toGameDoc(2026, baseGame({ gameId: null })); expect(doc).toBeNull(); }); }); function baseLiveRecord(overrides: Partial = {}): GameListRecord { return { gameId: "20260430HTLG0", date: "20260430", time: "18:30", season: 2026, stadium: "잠실", homeTeamCode: "LG", awayTeamCode: "HT", homeTeamName: "LG", awayTeamName: "KIA", homeRank: null, awayRank: null, broadcast: "", status: { stateCode: "1", cancelCode: "0", cancelName: "", inning: null, topBottom: null, }, score: { home: null, away: null }, count: { ball: null, strike: null, out: null }, runners: { first: null, second: null, third: null }, currentBatter: null, currentPitcher: null, startingPitchers: { away: null, home: null }, decisions: { winner: null, loser: null, save: null }, ...overrides, } as GameListRecord; } describe("gameSyncService.gameUpdateFromRecord", () => { it("stateCode=1 (예정)은 status=scheduled, winningTeamCode 없음", () => { const u = gameUpdateFromRecord(baseLiveRecord()); expect(u).toEqual({ status: "scheduled" }); }); it("stateCode=2 (진행 중)은 status=live", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "2", cancelCode: "0", cancelName: "", inning: 5, topBottom: "T" }, }) ); expect(u).toEqual({ status: "live" }); }); it("stateCode=3 + 홈 승리 점수면 status=completed + winningTeamCode=홈", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null }, score: { home: 7, away: 2 }, }) ); expect(u).toEqual({ status: "completed", winningTeamCode: "LG" }); }); it("stateCode=3 + 원정 승리면 winningTeamCode=원정", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null }, score: { home: 1, away: 5 }, }) ); expect(u).toEqual({ status: "completed", winningTeamCode: "HT" }); }); it("stateCode=3 + 무승부면 winningTeamCode 없음", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "3", cancelCode: "0", cancelName: "", inning: null, topBottom: null }, score: { home: 3, away: 3 }, }) ); expect(u).toEqual({ status: "completed" }); }); it("cancelCode != '0' + cancelName 있으면 status=cancelled + cancelReason 저장", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "1", cancelCode: "1", cancelName: "우천취소", inning: null, topBottom: null }, }) ); expect(u).toEqual({ status: "cancelled", cancelReason: "우천취소" }); }); it("cancelCode != '0'이지만 cancelName 비어있으면 cancelReason 미설정", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "1", cancelCode: "1", cancelName: "", inning: null, topBottom: null }, }) ); expect(u).toEqual({ status: "cancelled" }); }); it("알 수 없는 stateCode면 null 반환", () => { const u = gameUpdateFromRecord( baseLiveRecord({ status: { stateCode: "9", cancelCode: "0", cancelName: "", inning: null, topBottom: null }, }) ); expect(u).toBeNull(); }); }); describe("gameSyncService.syncGamesForMonth (실제 KBO 호출)", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("games")); await firestore.recursiveDelete(firestore.collection("kboCache")); await firestore.recursiveDelete(firestore.collection("kboLocks")); }); it("실제 KBO 일정을 fetch하여 games 컬렉션에 upsert한다", async () => { console.log("[sync] 2025-04 경기 sync 시작"); const t0 = Date.now(); const count = await syncGamesForMonth(2025, 4); console.log(`[sync] ${count}경기 저장 완료 (${Date.now() - t0}ms)`); expect(count).toBeGreaterThan(0); const snap = await firestore.collection("games").get(); // 더블헤더 등으로 같은 gameId가 반복되면 문서 수가 count보다 작을 수 있다 expect(snap.size).toBeGreaterThan(0); expect(snap.size).toBeLessThanOrEqual(count); const first = snap.docs[0].data(); console.log("[sync] 예시 문서:", { id: snap.docs[0].id, ...first }); expect(first).toHaveProperty("homeTeamCode"); expect(first).toHaveProperty("awayTeamCode"); expect(first).toHaveProperty("status"); expect(first).toHaveProperty("time"); }, 30000); it("동일 월을 두 번 sync해도 중복 없이 upsert된다", async () => { const first = await syncGamesForMonth(2025, 4); const snapAfterFirst = await firestore.collection("games").get(); console.log(`[idempotent] 1차: ${first}건`); const second = await syncGamesForMonth(2025, 4); const snapAfterSecond = await firestore.collection("games").get(); console.log(`[idempotent] 2차: ${second}건`); expect(snapAfterSecond.size).toBe(snapAfterFirst.size); }, 30000); });