brackt/app/services/standings-sync/__tests__/nhl.test.ts
Chris Parsons bcca8b76fa
Add regular season standings for NBA/NHL (fixes #89) (#192)
Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.

- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
  externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
  playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:12:01 -07:00

141 lines
3.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NhlStandingsAdapter } from "../nhl";
const SAMPLE_NHL_RESPONSE = {
standings: [
{
teamName: { default: "Boston Bruins" },
teamAbbrev: { default: "BOS" },
conferenceName: "Eastern",
divisionName: "Atlantic",
gamesPlayed: 68,
wins: 42,
losses: 20,
otLosses: 6,
points: 90,
pointPctg: 0.662,
winPctg: 0.618,
l10Wins: 7,
l10Losses: 2,
l10OtLosses: 1,
streakCode: "W",
streakCount: 3,
homeWins: 24,
homeLosses: 9,
homeOtLosses: 2,
roadWins: 18,
roadLosses: 11,
roadOtLosses: 4,
conferenceSequence: 1,
divisionSequence: 1,
leagueSequence: 1,
},
{
teamName: { default: "Toronto Maple Leafs" },
teamAbbrev: { default: "TOR" },
conferenceName: "Eastern",
divisionName: "Atlantic",
gamesPlayed: 70,
wins: 38,
losses: 25,
otLosses: 7,
points: 83,
pointPctg: 0.593,
winPctg: 0.543,
l10Wins: 5,
l10Losses: 4,
l10OtLosses: 1,
streakCode: "L",
streakCount: 2,
homeWins: 20,
homeLosses: 12,
homeOtLosses: 3,
roadWins: 18,
roadLosses: 13,
roadOtLosses: 4,
conferenceSequence: 3,
divisionSequence: 2,
leagueSequence: 5,
},
],
};
describe("NhlStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("maps NHL API response to FetchedStandingsRecord[]", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_NHL_RESPONSE,
} as Response);
const adapter = new NhlStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records).toHaveLength(2);
const bos = records[0];
expect(bos.teamName).toBe("Boston Bruins");
expect(bos.externalTeamId).toBe("BOS");
expect(bos.wins).toBe(42);
expect(bos.losses).toBe(20);
expect(bos.otLosses).toBe(6);
expect(bos.gamesPlayed).toBe(68);
expect(bos.conference).toBe("Eastern");
expect(bos.division).toBe("Atlantic");
expect(bos.conferenceRank).toBe(1);
expect(bos.divisionRank).toBe(1);
expect(bos.leagueRank).toBe(1);
});
it("formats streak correctly", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_NHL_RESPONSE,
} as Response);
const adapter = new NhlStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records[0].streak).toBe("W3");
expect(records[1].streak).toBe("L2");
});
it("formats lastTen as W-L-OTL", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_NHL_RESPONSE,
} as Response);
const adapter = new NhlStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records[0].lastTen).toBe("7-2-1");
});
it("formats home and away records", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_NHL_RESPONSE,
} as Response);
const adapter = new NhlStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records[0].homeRecord).toBe("24-9-2");
expect(records[0].awayRecord).toBe("18-11-4");
});
it("throws on non-ok response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const adapter = new NhlStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("503");
});
});