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>
411 lines
15 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|