diff --git a/.env.example b/.env.example index 4d657cc..90bd708 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,8 @@ CLOUDINARY_API_SECRET="" # Must match the CRON_SECRET repo secret in Forgejo. # Generate with: openssl rand -hex 32 CRON_SECRET="" + +# OC Blacktop motorsport API — used to sync IndyCar championship standings +# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api. +# If unset, IndyCar standings fall back to ESPN. +OCBLACKTOP_API_KEY="" diff --git a/app/services/standings-sync/__tests__/sync.test.ts b/app/services/standings-sync/__tests__/sync.test.ts index 67ad095..48ebafa 100644 --- a/app/services/standings-sync/__tests__/sync.test.ts +++ b/app/services/standings-sync/__tests__/sync.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { findMatchingTeamName } from "~/lib/normalize-team-name"; +import { OcBlacktopIndyCarStandingsAdapter } from "../f1"; +import type { FetchedStandingsRecord, StandingsSyncAdapter } from "../types"; // Pure unit tests for the name-matching logic used by syncStandings. // The full orchestrator requires DB context; those are covered by integration tests. @@ -46,3 +48,143 @@ describe("findMatchingTeamName (sync name-matching logic)", () => { expect(findMatchingTeamName(" Boston Celtics ", participants)).toBe("Boston Celtics"); }); }); + +describe("OcBlacktopIndyCarStandingsAdapter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // A fallback that records whether it was invoked and returns a sentinel result. + function makeFallback(): StandingsSyncAdapter & { called: boolean } { + const stub = { + called: false, + async fetchStandings(): Promise { + stub.called = true; + return [ + { + teamName: "ESPN Fallback Driver", + externalTeamId: "espn-1", + wins: 0, + losses: 0, + winPct: 0, + gamesPlayed: 0, + leagueRank: 1, + currentPoints: 1, + }, + ]; + }, + }; + return stub; + } + + it("maps OC Blacktop driver standings to FetchedStandingsRecord", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { id: "ocb-palou", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" }, + { id: "ocb-kirkwood", position: 2, points: "348.00", firstName: "Kyle", lastName: "Kirkwood" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const fallback = makeFallback(); + const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback); + const records = await adapter.fetchStandings(); + + expect(fallback.called).toBe(false); + expect(records).toEqual([ + expect.objectContaining({ + teamName: "Alex Palou", + externalTeamId: "ocb-palou", + leagueRank: 1, + currentPoints: 409, + }), + expect.objectContaining({ + teamName: "Kyle Kirkwood", + externalTeamId: "ocb-kirkwood", + leagueRank: 2, + currentPoints: 348, + }), + ]); + }); + + it("falls back to ESPN when the API responds non-OK", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("nope", { status: 500, statusText: "Internal Server Error" }) + ); + + const fallback = makeFallback(); + const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback); + const records = await adapter.fetchStandings(); + + expect(fallback.called).toBe(true); + expect(records[0].teamName).toBe("ESPN Fallback Driver"); + }); + + it("falls back to ESPN when the payload is empty", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }) + ); + + const fallback = makeFallback(); + const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback); + await adapter.fetchStandings(); + + expect(fallback.called).toBe(true); + }); + + it("falls back to ESPN when the payload is missing expected fields (schema drift)", async () => { + // Non-empty array, but `points` was renamed upstream — must fail over, not + // silently sync zeros over real standings. + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { id: "ocb-palou", position: 1, championshipPoints: "409.00", firstName: "Alex", lastName: "Palou" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const fallback = makeFallback(); + const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback); + const records = await adapter.fetchStandings(); + + expect(fallback.called).toBe(true); + expect(records[0].teamName).toBe("ESPN Fallback Driver"); + }); + + it("keeps legitimate zero-point drivers (0 points is valid, not a failure)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { id: "ocb-1", position: 1, points: "409.00", firstName: "Alex", lastName: "Palou" }, + { id: "ocb-2", position: 33, points: "0.00", firstName: "Rookie", lastName: "Driver" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const fallback = makeFallback(); + const adapter = new OcBlacktopIndyCarStandingsAdapter("test-key", fallback); + const records = await adapter.fetchStandings(); + + expect(fallback.called).toBe(false); + expect(records[1]).toEqual( + expect.objectContaining({ teamName: "Rookie Driver", leagueRank: 33, currentPoints: 0 }) + ); + }); + + it("falls back to ESPN when no API key is configured", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const fallback = makeFallback(); + // Empty string is falsy but (unlike undefined) does not trigger the env default. + const adapter = new OcBlacktopIndyCarStandingsAdapter("", fallback); + const records = await adapter.fetchStandings(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(fallback.called).toBe(true); + expect(records[0].teamName).toBe("ESPN Fallback Driver"); + }); +}); diff --git a/app/services/standings-sync/f1.ts b/app/services/standings-sync/f1.ts index 4bdc2e8..e25b6d2 100644 --- a/app/services/standings-sync/f1.ts +++ b/app/services/standings-sync/f1.ts @@ -1,5 +1,6 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; import { statsMap, type EspnStat } from "./espn"; +import { logger } from "~/lib/logger"; // Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed. const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json"; @@ -8,6 +9,11 @@ const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/dri const ESPN_INDYCAR_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/racing/irl/standings"; +// OC Blacktop IndyCar driver standings — updates within hours of a race finishing, +// unlike ESPN's aggregate which can lag most of a day. Requires an API key. +const OCBLACKTOP_INDYCAR_STANDINGS_URL = + "https://api.ocblacktop.com/v1/indycar/standings/drivers"; + interface JolpicaDriverStanding { position: string; points: string; @@ -137,3 +143,92 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter { }); } } + +// OC Blacktop returns a flat array of driver standings. Fields are typed as +// optional because this is untrusted external JSON — fetchFromOcBlacktop validates +// them at runtime before use. +interface OcBlacktopDriverStanding { + id?: string; + position?: number; + points?: string; // e.g. "409.00" + firstName?: string; + lastName?: string; +} + +/** + * IndyCar standings from OC Blacktop, which posts updated championship points within + * hours of a race — far fresher than ESPN's aggregate, which can stay stale most of a + * day. Falls back to ESPN on any failure (missing key, network error, empty response) + * so a flaky third party never breaks the sync. + */ +export class OcBlacktopIndyCarStandingsAdapter implements StandingsSyncAdapter { + constructor( + private readonly apiKey = process.env.OCBLACKTOP_API_KEY, + private readonly fallback: StandingsSyncAdapter = new IndyCarStandingsAdapter() + ) {} + + async fetchStandings(): Promise { + try { + return await this.fetchFromOcBlacktop(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `OC Blacktop IndyCar standings unavailable (${message}); falling back to ESPN.` + ); + return this.fallback.fetchStandings(); + } + } + + private async fetchFromOcBlacktop(): Promise { + if (!this.apiKey) { + throw new Error( + "OCBLACKTOP_API_KEY is required to sync IndyCar standings from OC Blacktop." + ); + } + + const res = await fetch(OCBLACKTOP_INDYCAR_STANDINGS_URL, { + headers: { "x-api-key": this.apiKey }, + }); + + if (!res.ok) { + throw new Error( + `OC Blacktop IndyCar standings API returned ${res.status}: ${res.statusText}` + ); + } + + const drivers = (await res.json()) as OcBlacktopDriverStanding[]; + + if (!Array.isArray(drivers) || drivers.length === 0) { + throw new Error( + "OC Blacktop IndyCar standings API returned no entries — season may not have started or response shape changed" + ); + } + + return drivers.map((d): FetchedStandingsRecord => { + // Validate the fields we depend on rather than coercing missing data into + // plausible-looking values. A field going missing/renamed upstream means the + // response shape drifted — throwing routes us to the ESPN fallback instead of + // silently overwriting real standings with zeros. (A 0-point backmarker is + // legitimate; an *unparseable* points value is not.) + const points = Number.parseFloat(d.points ?? ""); + const position = Number(d.position); + const name = `${d.firstName ?? ""} ${d.lastName ?? ""}`.trim(); + if (!d.id || !name || !Number.isFinite(position) || !Number.isFinite(points)) { + throw new Error( + `OC Blacktop IndyCar standings entry is missing expected fields (${JSON.stringify(d)}) — response shape may have changed` + ); + } + + return { + teamName: name, + externalTeamId: d.id, + leagueRank: position, + wins: 0, + losses: 0, + gamesPlayed: 0, + winPct: 0, + currentPoints: points, + }; + }); + } +} diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index b692b56..9a9a61f 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -13,7 +13,7 @@ import { MlbStandingsAdapter } from "./mlb"; import { WnbaStandingsAdapter } from "./wnba"; import { EplStandingsAdapter } from "./epl"; import { MlsStandingsAdapter } from "./mls"; -import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1"; +import { F1StandingsAdapter, OcBlacktopIndyCarStandingsAdapter } from "./f1"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; function getAdapter(simulatorType: string): StandingsSyncAdapter { @@ -35,7 +35,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter { case "f1_standings": return new F1StandingsAdapter(); case "indycar_standings": - return new IndyCarStandingsAdapter(); + return new OcBlacktopIndyCarStandingsAdapter(); default: throw new Error( `No standings sync adapter available for simulator type "${simulatorType}". ` +