brackt/app/services/match-sync/__tests__/espn-schedule.test.ts
Claude ac33e9e223
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m50s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 51s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
fix: address 10 code review issues in match sync system
- Fix 1 (CRITICAL): CS2 "Sync from API" button was always 401 because it
  POSTed to /admin/jobs/sync-matches which requires a cron secret header
  that browsers can't send. Added sync-matches intent to cs2-setup action.

- Fix 2 (CRITICAL): loserAdvances was hardcoded false in match-sync/index.ts,
  breaking NBA Play-In scoring. Now calls doesLoserAdvance().

- Fix 3: ESPN score "0" was falsy → stored as null. Non-numeric strings
  like "F/OT" produced NaN. Added parseScore() helper.

- Fix 4: ESPN limit=1000 silently truncated MLB/NBA playoff games. Added
  seasonType param to EspnScheduleAdapter; all *_bracket types pass 3
  (postseason only).

- Fix 5: Cron job was syncing completed seasons unnecessarily. Added
  active status filter.

- Fix 6: hasLiveMatches triggered perpetual 30s polling for unseeded
  brackets. Added participant presence check.

- Fix 7: autoCompleteRoundIfDone was duplicated between bracket.server.ts
  and scoring-calculator.ts. Removed inline copy, imported shared version.

- Fix 8: MatchSchedule date grouping never ran for ESPN sports (no matchday
  field). Removed hasMatchdays gate, always call groupByMatchday with
  stable UTC date key.

- Fix 9: Silent failure when bracket matches exist but no scoring events.
  Added warning log.

- Fix 10: Added 3 tests — zero score preservation, NaN from non-numeric
  score, seasonType in URL.

https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 21:53:06 +00:00

236 lines
7.3 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();
});
it("preserves score of 0 — does not coerce falsy string to null", async () => {
const response = {
events: [
{
id: "777",
date: "2025-04-05T18:00:00Z",
competitions: [
{
competitors: [
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "0", homeAway: "home" as const, winner: false },
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "3", homeAway: "away" as const, winner: true },
],
status: { type: { name: "STATUS_FINAL", completed: true } },
matchday: null,
},
],
},
],
};
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
expect(m.team1Score).toBe(0);
expect(m.team2Score).toBe(3);
});
it("returns null (not NaN) for non-numeric score strings", async () => {
const response = {
events: [
{
id: "778",
date: "2025-04-06T18:00:00Z",
competitions: [
{
competitors: [
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "F/OT", homeAway: "home" as const, winner: true },
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "TBD", homeAway: "away" as const, winner: false },
],
status: { type: { name: "STATUS_FINAL", completed: true } },
matchday: null,
},
],
},
],
};
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
expect(m.team1Score).toBeNull();
expect(m.team2Score).toBeNull();
});
it("appends seasontype param to URL when seasonType is provided", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ events: [] }),
} as Response);
await new EspnScheduleAdapter("baseball/mlb", 3).fetchMatches("2025");
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("seasontype=3")
);
});
});