Sync IndyCar standings from OC Blacktop, fall back to ESPN
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m29s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m29s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
ESPN's IndyCar standings aggregate lags hours behind a race finish (Palou stuck at 374 an evening after Mid-Ohio, vs. his true 409/P1). Add an OcBlacktopIndyCarStandingsAdapter that reads the fresher OC Blacktop feed and delegates to the existing ESPN IndyCarStandingsAdapter on any failure (missing key, network error, empty payload, or schema drift) so a flaky third party never breaks the sync. Per-row validation throws on missing id/name or unparseable position/points to route to the fallback rather than overwriting real standings with zeros, while still keeping legitimate 0-point backmarkers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3f6af2665e
commit
07f1356cd3
4 changed files with 245 additions and 3 deletions
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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<FetchedStandingsRecord[]> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<FetchedStandingsRecord[]> {
|
||||
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<FetchedStandingsRecord[]> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}". ` +
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue