Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
171 lines
5.1 KiB
TypeScript
171 lines
5.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { EspnScheduleAdapter } from "../espn-schedule";
|
|
|
|
const SAMPLE_RESPONSE = {
|
|
events: [
|
|
{
|
|
id: "401234567",
|
|
date: "2025-04-01T18:00:00Z",
|
|
competitions: [
|
|
{
|
|
competitors: [
|
|
{
|
|
id: "9",
|
|
team: { id: "9", displayName: "Boston Red Sox" },
|
|
score: "5",
|
|
homeAway: "home" as const,
|
|
winner: true,
|
|
},
|
|
{
|
|
id: "4",
|
|
team: { id: "4", displayName: "New York Yankees" },
|
|
score: "3",
|
|
homeAway: "away" as const,
|
|
winner: false,
|
|
},
|
|
],
|
|
status: { type: { name: "STATUS_FINAL", completed: true } },
|
|
matchday: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "401234568",
|
|
date: "2025-04-02T19:00:00Z",
|
|
competitions: [
|
|
{
|
|
competitors: [
|
|
{
|
|
id: "12",
|
|
team: { id: "12", displayName: "Los Angeles Dodgers" },
|
|
score: "0",
|
|
homeAway: "home" as const,
|
|
winner: false,
|
|
},
|
|
{
|
|
id: "15",
|
|
team: { id: "15", displayName: "San Francisco Giants" },
|
|
score: "0",
|
|
homeAway: "away" as const,
|
|
winner: false,
|
|
},
|
|
],
|
|
status: { type: { name: "STATUS_SCHEDULED", completed: false } },
|
|
matchday: null,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("EspnScheduleAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("maps completed matches with scores and winner", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new EspnScheduleAdapter("baseball/mlb");
|
|
const matches = await adapter.fetchMatches("2025");
|
|
|
|
expect(matches).toHaveLength(2);
|
|
const completed = matches[0];
|
|
expect(completed.externalMatchId).toBe("401234567");
|
|
expect(completed.team1ExternalId).toBe("9");
|
|
expect(completed.team1Name).toBe("Boston Red Sox");
|
|
expect(completed.team2ExternalId).toBe("4");
|
|
expect(completed.team2Name).toBe("New York Yankees");
|
|
expect(completed.team1Score).toBe(5);
|
|
expect(completed.team2Score).toBe(3);
|
|
expect(completed.winnerExternalId).toBe("9");
|
|
expect(completed.status).toBe("complete");
|
|
expect(completed.completedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("maps scheduled matches with null scores", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_RESPONSE,
|
|
} as Response);
|
|
|
|
const [, scheduled] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
|
expect(scheduled.status).toBe("scheduled");
|
|
expect(scheduled.winnerExternalId).toBeNull();
|
|
expect(scheduled.completedAt).toBeNull();
|
|
expect(scheduled.startedAt).toBeNull();
|
|
});
|
|
|
|
it("sets matchStage to null for all ESPN matches", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_RESPONSE,
|
|
} as Response);
|
|
|
|
const matches = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
|
for (const m of matches) {
|
|
expect(m.matchStage).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("builds correct ESPN URL with sport path and dates param", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ events: [] }),
|
|
} as Response);
|
|
|
|
await new EspnScheduleAdapter("basketball/nba").fetchMatches("2025");
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining("basketball/nba")
|
|
);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining("dates=2025")
|
|
);
|
|
});
|
|
|
|
it("throws on non-ok API response", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
text: async () => "Too Many Requests",
|
|
} as Response);
|
|
|
|
await expect(new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025")).rejects.toThrow(
|
|
"ESPN API error 429"
|
|
);
|
|
});
|
|
|
|
it("handles in-progress matches", async () => {
|
|
const response = {
|
|
events: [
|
|
{
|
|
id: "999",
|
|
date: "2025-04-03T20:00:00Z",
|
|
competitions: [
|
|
{
|
|
competitors: [
|
|
{ id: "1", team: { id: "1", displayName: "Home Team" }, score: "2", homeAway: "home" as const },
|
|
{ id: "2", team: { id: "2", displayName: "Away Team" }, score: "1", homeAway: "away" as const },
|
|
],
|
|
status: { type: { name: "STATUS_IN_PROGRESS", completed: false } },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
|
|
|
|
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
|
expect(m.status).toBe("in_progress");
|
|
expect(m.startedAt).toBeInstanceOf(Date);
|
|
expect(m.completedAt).toBeNull();
|
|
});
|
|
});
|