brackt/app/models/__tests__/sports-season.clone.test.ts
Chris Parsons 31b48f5a05
Remove sports_season_tournaments junction table — derive from scoring_events (#373)
The junction table was a redundant second source of truth: syncTournamentResults
already fans out by querying scoring_events.tournamentId directly, so the table
only served the admin UI and could drift out of sync (causing orphaned events).

- Drop sports_season_tournaments table and all link/unlink admin actions
- Add getTournamentsBySportsSeason / getSportsSeasonsByTournament helpers that
  derive the same information from scoring_events.tournamentId
- Add "Add Existing Tournament" dropdown to the events admin page (qualifying_points
  seasons only) — selecting a tournament creates the scoring event in one step
- Fix clone: scoring events now carry tournamentId, fixing a latent check-constraint
  violation when cloning qualifying_points seasons
- Tournament admin page "Linked Sports Seasons" is now a read-only derived view

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 22:19:59 -07:00

411 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/database/schema", () => ({
sportsSeasons: { id: "ss.id", sportId: "ss.sport_id" },
seasonParticipants: { sportsSeasonId: "p.sports_season_id" },
scoringEvents: { sportsSeasonId: "se.sports_season_id" },
seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
desc: (col: unknown) => ({ type: "desc", col }),
asc: (col: unknown) => ({ type: "asc", col }),
}));
vi.mock("~/models/qualifying-points", () => ({
getQPConfig: vi.fn(),
updateQPConfig: vi.fn(),
}));
import * as schema from "~/database/schema";
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
import { database } from "~/database/context";
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
const SOURCE_ID = "source-season-id";
const NEW_ID = "new-season-id";
const sourceSeason = {
id: SOURCE_ID,
sportId: "sport-1",
name: "2025 F1 Season",
year: 2025,
startDate: "2025-03-16",
endDate: "2025-12-07",
draftOn: "2025-01-01",
draftOff: "2025-03-15",
status: "completed",
scoringType: "majors",
scoringPattern: "season_standings",
totalMajors: null,
majorsCompleted: 5,
qualifyingPointsFinalized: true,
eloCalibrationExponent: "0.33",
eloMinRating: 1250,
eloMaxRating: 1750,
simulationStatus: "idle",
};
const newSeasonData: NewSportsSeason = {
sportId: "sport-1",
name: "2026 F1 Season",
year: 2026,
startDate: "2026-03-16",
endDate: "2026-12-07",
draftOn: "2026-01-01",
draftOff: "2026-03-15",
status: "upcoming",
simulationStatus: "idle",
majorsCompleted: 0,
qualifyingPointsFinalized: false,
scoringType: "majors",
scoringPattern: "season_standings",
};
function makeMockDb({
sourceSeason: ss = sourceSeason,
participants = [] as object[],
scoringEvents = [] as object[],
sourceEvRows = [] as object[],
insertedSeason = { ...newSeasonData, id: NEW_ID } as object,
// New participants returned by the INSERT ... RETURNING — mirrors sources with new IDs by default
newParticipantRows = (participants as Array<Record<string, unknown>>).map((p, i) => ({
...p,
id: `new-p${i + 1}`,
sportsSeasonId: NEW_ID,
})) as object[],
} = {}) {
// Track what was inserted into each table independently of call order
const insertedRows: Record<string, unknown> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db: any = {
query: {
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(ss) },
seasonParticipants: { findMany: vi.fn().mockResolvedValue(participants) },
scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) },
seasonParticipantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
},
insert: vi.fn().mockImplementation((table: object) => {
let key: string;
let returnRows: object[];
if (table === schema.sportsSeasons) {
key = "sportsSeasons"; returnRows = [insertedSeason];
} else if (table === schema.seasonParticipants) {
key = "participants"; returnRows = newParticipantRows;
} else if (table === schema.scoringEvents) {
key = "scoringEvents"; returnRows = [];
} else {
key = "participantExpectedValues"; returnRows = [];
}
return {
values: vi.fn().mockImplementation((vals: unknown) => {
insertedRows[key] = vals;
return { returning: vi.fn().mockResolvedValue(returnRows) };
}),
};
}),
// Passes `db` as the transaction object so tracked inserts still work
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
_insertedRows: insertedRows,
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("cloneSportsSeason", () => {
it("creates new season with reset fields (status=upcoming, majorsCompleted=0, simulationStatus=idle)", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.sportsSeasons).toMatchObject({
name: "2026 F1 Season",
year: 2026,
status: "upcoming",
simulationStatus: "idle",
majorsCompleted: 0,
qualifyingPointsFinalized: false,
});
});
it("merges ELO calibration fields from source season", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.sportsSeasons).toMatchObject({
eloCalibrationExponent: "0.33",
eloMinRating: 1250,
eloMaxRating: 1750,
});
});
it("copies participants with name, shortName, externalId — not expectedValue", async () => {
const participants = [
{ id: "p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1", expectedValue: "42.5" },
{ id: "p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2", expectedValue: "38.0" },
];
const db = makeMockDb({ participants });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const rows = db._insertedRows.participants as object[];
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
sportsSeasonId: NEW_ID,
name: "Max Verstappen",
shortName: "VER",
externalId: "ext-1",
});
expect(rows[0]).not.toHaveProperty("expectedValue");
expect(rows[0]).not.toHaveProperty("id");
});
it("skips participant insert when source has no participants", async () => {
const db = makeMockDb({ participants: [] });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.participants).toBeUndefined();
});
it("copies scoring events with dates shifted by yearDelta", async () => {
const scoringEvents = [
{ id: "e1", name: "Bahrain GP", eventType: "final_standings", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: "2025-03-16", eventStartsAt: null },
{ id: "e2", name: "Abu Dhabi GP", eventType: "final_standings", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: "2025-12-07", eventStartsAt: null },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const rows = db._insertedRows.scoringEvents as object[];
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
sportsSeasonId: NEW_ID,
name: "Bahrain GP",
eventDate: "2026-03-16",
isComplete: false,
});
expect(rows[1]).toMatchObject({
name: "Abu Dhabi GP",
eventDate: "2026-12-07",
});
});
it("leaves eventDate null when source event has no date", async () => {
const scoringEvents = [
{ id: "e1", name: "TBD Event", eventType: "schedule_event", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: null, eventStartsAt: null },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const rows = db._insertedRows.scoringEvents as Array<{ eventDate: unknown }>;
expect(rows[0].eventDate).toBeNull();
});
it("shifts eventStartsAt timestamp by yearDelta", async () => {
const originalDate = new Date("2025-06-15T14:00:00Z");
const scoringEvents = [
{ id: "e1", name: "Mid-year Event", eventType: "playoff_game", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: null, eventStartsAt: originalDate },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const rows = db._insertedRows.scoringEvents as Array<{ eventStartsAt: Date }>;
const shiftedDate = rows[0].eventStartsAt;
expect(shiftedDate.getUTCFullYear()).toBe(2026);
expect(shiftedDate.getUTCMonth()).toBe(originalDate.getUTCMonth());
expect(shiftedDate.getUTCDate()).toBe(originalDate.getUTCDate());
});
it("copies futures odds as stub EV records matched by externalId", async () => {
const participants = [
{ id: "src-p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1" },
{ id: "src-p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2" },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: -120, sourceElo: null, worldRanking: null },
{ participantId: "src-p2", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: 300, sourceElo: null, worldRanking: null },
];
const newParticipantRows = [
{ id: "new-p1", name: "Max Verstappen", externalId: "ext-1", sportsSeasonId: NEW_ID },
{ id: "new-p2", name: "Lewis Hamilton", externalId: "ext-2", sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(2);
expect(evRows[0]).toMatchObject({
participantId: "new-p1",
sportsSeasonId: NEW_ID,
source: "futures_odds",
sourceOdds: -120,
sourceElo: null,
expectedValue: "0",
probFirst: "0",
});
expect(evRows[1]).toMatchObject({ participantId: "new-p2", sourceOdds: 300 });
});
it("copies Elo ratings and world rankings as stub EV records", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "elo_simulation", sourceOdds: null, sourceElo: 1650, worldRanking: 3 },
];
const newParticipantRows = [
{ id: "new-p1", name: "Player A", externalId: "ext-1", sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(1);
expect(evRows[0]).toMatchObject({
participantId: "new-p1",
source: "elo_simulation",
sourceElo: 1650,
worldRanking: 3,
sourceOdds: null,
});
});
it("falls back to name matching when externalId is null", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: null },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: 200, sourceElo: null, worldRanking: null },
];
const newParticipantRows = [
{ id: "new-p1", name: "Player A", externalId: null, sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(1);
expect(evRows[0]).toMatchObject({ participantId: "new-p1", sourceOdds: 200 });
});
it("skips EV copy when source has no odds or Elo data", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
// EV row exists but has no input data — e.g. only calculated probabilities
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "manual", sourceOdds: null, sourceElo: null, worldRanking: null },
];
const db = makeMockDb({ participants, sourceEvRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
});
it("skips EV copy when source season has no EV rows", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
const db = makeMockDb({ participants, sourceEvRows: [] });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
});
it("copies QP config when scoringPattern is qualifying_points", async () => {
const qpSeason: NewSportsSeason = {
...newSeasonData,
scoringPattern: "qualifying_points",
totalMajors: 4,
};
const mockQPConfig = [
{ placement: 1, points: "20" },
{ placement: 2, points: "14" },
];
vi.mocked(getQPConfig).mockResolvedValue(mockQPConfig as never);
vi.mocked(updateQPConfig).mockResolvedValue([] as never);
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, qpSeason);
expect(getQPConfig).toHaveBeenCalledWith(SOURCE_ID, db);
expect(updateQPConfig).toHaveBeenCalledWith(
NEW_ID,
[
{ placement: 1, points: 20 },
{ placement: 2, points: 14 },
],
db
);
});
it("does NOT copy QP config for non-QP seasons", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(getQPConfig).not.toHaveBeenCalled();
expect(updateQPConfig).not.toHaveBeenCalled();
});
it("throws when source season is not found", async () => {
const db = makeMockDb();
db.query.sportsSeasons.findFirst = vi.fn().mockResolvedValue(undefined);
vi.mocked(database).mockReturnValue(db as never);
await expect(cloneSportsSeason("nonexistent-id", newSeasonData)).rejects.toThrow(
"Source season nonexistent-id not found"
);
});
});