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