Implements F1StandingsAdapter (Jolpica/Ergast API) and IndyCarStandingsAdapter (ESPN IRL API), wires them into the syncStandings orchestrator with a season_standings branch that upserts into participantSeasonResults instead of regularSeasonStandings, and adds a "Championship Standings" card with a Sync Standings button to the admin sports season page for individual sports. https://claude.ai/code/session_012ACmUs2vAwEF9jZu7Guos2
290 lines
9.5 KiB
TypeScript
290 lines
9.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { F1StandingsAdapter, IndyCarStandingsAdapter } from "../f1";
|
|
|
|
// Minimal Jolpica driver standings response
|
|
const SAMPLE_JOLPICA_STANDINGS = {
|
|
MRData: {
|
|
StandingsTable: {
|
|
StandingsLists: [
|
|
{
|
|
DriverStandings: [
|
|
{
|
|
position: "1",
|
|
points: "195",
|
|
wins: "5",
|
|
Driver: { driverId: "max_verstappen", givenName: "Max", familyName: "Verstappen" },
|
|
},
|
|
{
|
|
position: "2",
|
|
points: "152",
|
|
wins: "2",
|
|
Driver: { driverId: "norris", givenName: "Lando", familyName: "Norris" },
|
|
},
|
|
{
|
|
position: "3",
|
|
points: "133",
|
|
wins: "1",
|
|
Driver: { driverId: "leclerc", givenName: "Charles", familyName: "Leclerc" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
// Minimal Jolpica schedule response — 8 races, 2 in the future (date > 2026-06-09)
|
|
const SAMPLE_JOLPICA_SCHEDULE = {
|
|
MRData: {
|
|
RaceTable: {
|
|
Races: [
|
|
{ date: "2026-03-16" },
|
|
{ date: "2026-03-23" },
|
|
{ date: "2026-04-06" },
|
|
{ date: "2026-04-20" },
|
|
{ date: "2026-05-04" },
|
|
{ date: "2026-05-25" },
|
|
{ date: "2026-06-08" },
|
|
{ date: "2026-07-06" }, // future
|
|
{ date: "2026-07-27" }, // future
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
// Minimal ESPN IndyCar standings response (flat, single standings list)
|
|
const SAMPLE_ESPN_INDYCAR = {
|
|
standings: {
|
|
entries: [
|
|
{
|
|
athlete: { id: "3097", displayName: "Scott Dixon" },
|
|
stats: [
|
|
{ name: "rank", value: 1 },
|
|
{ name: "points", value: 437 },
|
|
{ name: "wins", value: 3 },
|
|
{ name: "starts", value: 10 },
|
|
],
|
|
},
|
|
{
|
|
athlete: { id: "3098", displayName: "Alex Palou" },
|
|
stats: [
|
|
{ name: "rank", value: 2 },
|
|
{ name: "points", value: 398 },
|
|
{ name: "wins", value: 2 },
|
|
{ name: "starts", value: 10 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
// IndyCar response nested under children (alternate ESPN shape)
|
|
const SAMPLE_ESPN_INDYCAR_NESTED = {
|
|
children: [
|
|
{
|
|
standings: {
|
|
entries: SAMPLE_ESPN_INDYCAR.standings.entries,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("F1StandingsAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
it("returns one record per driver with correct names", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const adapter = new F1StandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
expect(records).toHaveLength(3);
|
|
expect(records[0].teamName).toBe("Max Verstappen");
|
|
expect(records[1].teamName).toBe("Lando Norris");
|
|
});
|
|
|
|
it("sets externalTeamId to driverId", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
expect(records[0].externalTeamId).toBe("max_verstappen");
|
|
expect(records[2].externalTeamId).toBe("leclerc");
|
|
});
|
|
|
|
it("maps championship points to currentPoints", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
expect(records[0].currentPoints).toBe(195);
|
|
expect(records[1].currentPoints).toBe(152);
|
|
});
|
|
|
|
it("sets leagueRank to championship position", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
expect(records[0].leagueRank).toBe(1);
|
|
expect(records[2].leagueRank).toBe(3);
|
|
});
|
|
|
|
it("counts only completed races for gamesPlayed", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
// 7 races on/before 2026-06-09, 2 in the future
|
|
expect(records[0].gamesPlayed).toBe(7);
|
|
});
|
|
|
|
it("computes winPct as wins / gamesPlayed", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
// Verstappen: 5 wins / 7 races
|
|
expect(records[0].winPct).toBeCloseTo(5 / 7, 5);
|
|
});
|
|
|
|
it("falls back gracefully when schedule fetch fails", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response)
|
|
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response);
|
|
|
|
const records = await new F1StandingsAdapter().fetchStandings();
|
|
// gamesPlayed falls back to 0 from countCompletedRaces
|
|
expect(records[0].gamesPlayed).toBe(0);
|
|
});
|
|
|
|
it("throws when standings fetch fails", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow("500");
|
|
});
|
|
|
|
it("throws when API returns empty standings", async () => {
|
|
const empty = { MRData: { StandingsTable: { StandingsLists: [] } } };
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => empty } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response);
|
|
|
|
await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(
|
|
/no entries/i
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("IndyCarStandingsAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
it("parses flat ESPN standings", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records).toHaveLength(2);
|
|
expect(records[0].teamName).toBe("Scott Dixon");
|
|
expect(records[1].teamName).toBe("Alex Palou");
|
|
});
|
|
|
|
it("parses nested ESPN standings (children[0].standings)", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR_NESTED,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records).toHaveLength(2);
|
|
expect(records[0].teamName).toBe("Scott Dixon");
|
|
});
|
|
|
|
it("sets externalTeamId to ESPN athlete id", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records[0].externalTeamId).toBe("3097");
|
|
});
|
|
|
|
it("maps championship points to currentPoints", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records[0].currentPoints).toBe(437);
|
|
expect(records[1].currentPoints).toBe(398);
|
|
});
|
|
|
|
it("sets leagueRank from rank stat", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records[0].leagueRank).toBe(1);
|
|
expect(records[1].leagueRank).toBe(2);
|
|
});
|
|
|
|
it("sets gamesPlayed from starts stat", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records[0].gamesPlayed).toBe(10);
|
|
});
|
|
|
|
it("computes winPct as wins / starts", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_ESPN_INDYCAR,
|
|
} as Response);
|
|
|
|
const records = await new IndyCarStandingsAdapter().fetchStandings();
|
|
expect(records[0].winPct).toBeCloseTo(3 / 10, 5);
|
|
});
|
|
|
|
it("throws when API returns HTTP error", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: "Not Found",
|
|
} as Response);
|
|
|
|
await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow("404");
|
|
});
|
|
|
|
it("throws when API returns no entries", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ standings: { entries: [] } }),
|
|
} as Response);
|
|
|
|
await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow(
|
|
/no entries/i
|
|
);
|
|
});
|
|
});
|