brackt/app/services/standings-sync/__tests__/mls.test.ts
Chris Parsons 766ba948e1
Add MLS standings sync and fix simulator conference resolution (#423)
* Add MLS standings sync and fix simulator conference resolution

- New MlsStandingsAdapter using ESPN's free soccer API (no key required),
  returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference
  rank, and home/away records; registered under mls_bracket
- Display route now shows soccer table columns and a 9-team playoff cutoff
  line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs)
- MLS simulator gains a third conference fallback: reads externalId="Eastern"
  or "Western" on the participant, mirroring the LLWS pool-assignment pattern
  so admins can bootstrap conference data before the first standings sync
- 9 unit tests covering stat mapping, conference normalization, rank ordering,
  and error paths

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

* Address code review feedback on MLS standings + simulator

1. Update mls-simulator.ts module comment to document the new step-3
   externalId fallback in the conference resolution order
2. Tighten normalizeConference to exact-match "Eastern"/"Western
   Conference" instead of broad substring, preventing false matches
   on names like "Northeast"
3. Pre-parse statsMap for each entry before the sort so it isn't
   rebuilt O(n log n) times during comparison
4. Document the externalId name-matching tradeoff on
   parseConferenceFromExternalId
5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties
   sum; export parseConferenceFromExternalId for direct testing
6. Add tests: normalizeConference passthrough, winPct=0 at preseason,
   and parseConferenceFromExternalId (case-insensitivity, null/undefined,
   numeric ESPN IDs, unrecognized strings)

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

* Fix lint: replace != null with !== null && !== undefined

oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged.

https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00

286 lines
9.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MlsStandingsAdapter } from "../mls";
function makeStat(name: string, value: number, displayValue?: string) {
return { name, value, displayValue: displayValue ?? String(value) };
}
const SAMPLE_MLS_RESPONSE = {
children: [
{
name: "Eastern Conference",
standings: {
entries: [
{
team: { id: "371", displayName: "Inter Miami CF", shortDisplayName: "Inter Miami", abbreviation: "MIA" },
stats: [
makeStat("wins", 18),
makeStat("losses", 8),
makeStat("ties", 8),
makeStat("points", 62),
makeStat("pointsFor", 55),
makeStat("pointsAgainst", 38),
makeStat("pointDifferential", 17),
makeStat("playoffSeed", 1),
{ name: "Home", value: 0, displayValue: "10-3-3" },
{ name: "Road", value: 0, displayValue: "8-5-5" },
],
},
{
team: { id: "928", displayName: "Columbus Crew", shortDisplayName: "Columbus", abbreviation: "CLB" },
stats: [
makeStat("wins", 15),
makeStat("losses", 10),
makeStat("ties", 9),
makeStat("points", 54),
makeStat("pointsFor", 48),
makeStat("pointsAgainst", 42),
makeStat("pointDifferential", 6),
makeStat("playoffSeed", 2),
{ name: "Home", value: 0, displayValue: "9-4-4" },
{ name: "Road", value: 0, displayValue: "6-6-5" },
],
},
],
},
},
{
name: "Western Conference",
standings: {
entries: [
{
team: { id: "339", displayName: "Los Angeles FC", shortDisplayName: "LAFC", abbreviation: "LAFC" },
stats: [
makeStat("wins", 20),
makeStat("losses", 6),
makeStat("ties", 8),
makeStat("points", 68),
makeStat("pointsFor", 62),
makeStat("pointsAgainst", 30),
makeStat("pointDifferential", 32),
makeStat("playoffSeed", 1),
{ name: "Home", value: 0, displayValue: "12-2-3" },
{ name: "Road", value: 0, displayValue: "8-4-5" },
],
},
],
},
},
],
};
describe("MlsStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("maps ESPN API response to FetchedStandingsRecord[]", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records).toHaveLength(3);
});
it("maps soccer-specific fields (W/D/L/GF/GA/GD/PTS)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
const miami = records.find((r) => r.teamName === "Inter Miami CF");
if (!miami) throw new Error("Inter Miami CF not found");
expect(miami.wins).toBe(18);
expect(miami.losses).toBe(8);
expect(miami.ties).toBe(8);
expect(miami.tablePoints).toBe(62);
expect(miami.goalsFor).toBe(55);
expect(miami.goalsAgainst).toBe(38);
expect(miami.goalDifference).toBe(17);
expect(miami.gamesPlayed).toBe(34); // 18+8+8
});
it("normalizes conference names to Eastern/Western", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
const miami = records.find((r) => r.teamName === "Inter Miami CF");
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
if (!miami || !lafc) throw new Error("Teams not found");
expect(miami.conference).toBe("Eastern");
expect(lafc.conference).toBe("Western");
});
it("assigns conferenceRank from playoffSeed stat", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
const miami = records.find((r) => r.teamName === "Inter Miami CF");
const columbus = records.find((r) => r.teamName === "Columbus Crew");
if (!miami || !columbus) throw new Error("Teams not found");
expect(miami.conferenceRank).toBe(1);
expect(columbus.conferenceRank).toBe(2);
});
it("assigns leagueRank by points descending", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
// LAFC has 68 pts, Miami 62, Columbus 54 → ranks 1, 2, 3
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
const miami = records.find((r) => r.teamName === "Inter Miami CF");
const columbus = records.find((r) => r.teamName === "Columbus Crew");
if (!lafc || !miami || !columbus) throw new Error("Teams not found");
expect(lafc.leagueRank).toBe(1);
expect(miami.leagueRank).toBe(2);
expect(columbus.leagueRank).toBe(3);
});
it("uses externalTeamId from ESPN team id", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
if (!lafc) throw new Error("LAFC not found");
expect(lafc.externalTeamId).toBe("339");
});
it("maps home and away records", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLS_RESPONSE,
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
const miami = records.find((r) => r.teamName === "Inter Miami CF");
if (!miami) throw new Error("Inter Miami CF not found");
expect(miami.homeRecord).toBe("10-3-3");
expect(miami.awayRecord).toBe("8-5-5");
});
it("throws on non-ok response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
const adapter = new MlsStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("429");
});
it("throws when no entries returned", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ children: [] }),
} as Response);
const adapter = new MlsStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("no entries");
});
it("passes through unrecognized conference names unchanged", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
children: [
{
name: "Southern Division",
standings: {
entries: [
{
team: { id: "1", displayName: "Test FC" },
stats: [
makeStat("wins", 5),
makeStat("losses", 3),
makeStat("ties", 2),
makeStat("points", 17),
makeStat("pointsFor", 12),
makeStat("pointsAgainst", 9),
makeStat("pointDifferential", 3),
makeStat("playoffSeed", 1),
],
},
],
},
},
],
}),
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records[0].conference).toBe("Southern Division");
});
it("returns winPct of 0 when gamesPlayed is 0", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
children: [
{
name: "Eastern Conference",
standings: {
entries: [
{
team: { id: "1", displayName: "Preseason FC" },
stats: [
makeStat("wins", 0),
makeStat("losses", 0),
makeStat("ties", 0),
makeStat("points", 0),
makeStat("pointsFor", 0),
makeStat("pointsAgainst", 0),
makeStat("pointDifferential", 0),
makeStat("playoffSeed", 1),
],
},
],
},
},
],
}),
} as Response);
const adapter = new MlsStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records[0].winPct).toBe(0);
expect(records[0].gamesPlayed).toBe(0);
});
});