209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
|
|
/**
|
||
|
|
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
||
|
|
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
||
|
|
* and whether it takes the rest of the season's placements down with it.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||
|
|
import {
|
||
|
|
deleteParticipantResultsBySportsSeasonId,
|
||
|
|
deleteParticipantResultsForParticipants,
|
||
|
|
setParticipantResult,
|
||
|
|
} from "~/models/participant-result";
|
||
|
|
import {
|
||
|
|
applyBracketEntryFloors,
|
||
|
|
processMatchResult,
|
||
|
|
processQualifyingBracketEvent,
|
||
|
|
recalculateAffectedLeagues,
|
||
|
|
} from "~/models/scoring-calculator";
|
||
|
|
import { getScoringEventById } from "~/models/scoring-event";
|
||
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||
|
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||
|
|
|
||
|
|
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||
|
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
getScoringEventById: vi.fn(),
|
||
|
|
updateScoringEvent: vi.fn(),
|
||
|
|
isReadOnlySibling: vi.fn(() => false),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
findPlayoffMatchesByEventId: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||
|
|
deleteParticipantResultsForParticipants: vi.fn(),
|
||
|
|
setParticipantResult: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
applyBracketEntryFloors: vi.fn(),
|
||
|
|
processMatchResult: vi.fn(),
|
||
|
|
recalculateAffectedLeagues: vi.fn(),
|
||
|
|
processQualifyingBracketEvent: vi.fn(),
|
||
|
|
finalizeQualifyingPoints: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
findParticipantsBySportsSeasonId: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
findSportsSeasonById: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const params = { id: "season-1", eventId: "event-1" };
|
||
|
|
|
||
|
|
const EVENT = {
|
||
|
|
id: "event-1",
|
||
|
|
name: "AFL Finals",
|
||
|
|
sportsSeasonId: "season-1",
|
||
|
|
isQualifyingEvent: false,
|
||
|
|
isPrimary: false,
|
||
|
|
tournamentId: null,
|
||
|
|
bracketTemplateId: "afl_10",
|
||
|
|
};
|
||
|
|
|
||
|
|
function reprocessRequest(): Request {
|
||
|
|
const body = new FormData();
|
||
|
|
body.set("intent", "reprocess-bracket");
|
||
|
|
return new Request("http://localhost/reprocess", { method: "POST", body });
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A seeded, unplayed bracket slot. */
|
||
|
|
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
||
|
|
return {
|
||
|
|
id: `m-${matchNumber}`,
|
||
|
|
round: "Qualifying Finals",
|
||
|
|
matchNumber,
|
||
|
|
participant1Id,
|
||
|
|
participant2Id,
|
||
|
|
winnerId: null,
|
||
|
|
loserId: null,
|
||
|
|
isComplete: false,
|
||
|
|
isScoring: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const run = (request: Request) =>
|
||
|
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||
|
|
error?: string;
|
||
|
|
success?: string;
|
||
|
|
}>)({ request, params });
|
||
|
|
|
||
|
|
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
||
|
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||
|
|
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setMatches(matches: ReturnType<typeof slot>[]) {
|
||
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||
|
|
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("reprocess-bracket", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
setEvent();
|
||
|
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||
|
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||
|
|
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
||
|
|
);
|
||
|
|
vi.mocked(setParticipantResult).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||
|
|
);
|
||
|
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||
|
|
);
|
||
|
|
vi.mocked(processMatchResult).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
||
|
|
);
|
||
|
|
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
||
|
|
);
|
||
|
|
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
||
|
|
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
||
|
|
ReturnType<typeof findSportsSeasonById>
|
||
|
|
>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clears placements even when no match has been played", async () => {
|
||
|
|
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
||
|
|
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
||
|
|
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
||
|
|
// because upsertParticipantResult refuses to un-finalize a result.
|
||
|
|
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
||
|
|
|
||
|
|
const result = await run(reprocessRequest());
|
||
|
|
|
||
|
|
expect(result.success).toBeDefined();
|
||
|
|
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
||
|
|
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||
|
|
expect(sportsSeasonId).toBe("season-1");
|
||
|
|
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("scopes the wipe to this bracket, never the whole season", async () => {
|
||
|
|
// A season-wide delete would take every other event's placements with it, with only
|
||
|
|
// this bracket's replay able to rebuild them.
|
||
|
|
setMatches([slot(1, "p1", "p2")]);
|
||
|
|
|
||
|
|
await run(reprocessRequest());
|
||
|
|
|
||
|
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||
|
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||
|
|
expect(ids).not.toContain("p3");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("passes each participant once when a team appears in more than one slot", async () => {
|
||
|
|
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
||
|
|
|
||
|
|
await run(reprocessRequest());
|
||
|
|
|
||
|
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||
|
|
expect(ids).toHaveLength(3);
|
||
|
|
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("skips empty slots rather than passing nulls through", async () => {
|
||
|
|
setMatches([
|
||
|
|
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
||
|
|
]);
|
||
|
|
|
||
|
|
await run(reprocessRequest());
|
||
|
|
|
||
|
|
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||
|
|
expect(ids).toEqual(["p1"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("still takes the season-wide delete for a qualifying event", async () => {
|
||
|
|
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
||
|
|
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
||
|
|
// on purpose and rebuilds QP from the bracket.
|
||
|
|
setEvent({ isQualifyingEvent: true });
|
||
|
|
setMatches([slot(1, "p1", "p2")]);
|
||
|
|
|
||
|
|
await run(reprocessRequest());
|
||
|
|
|
||
|
|
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
||
|
|
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rejects an event with no bracket rather than wiping anything", async () => {
|
||
|
|
setMatches([]);
|
||
|
|
|
||
|
|
const result = await run(reprocessRequest());
|
||
|
|
|
||
|
|
expect(result.error).toContain("No bracket to reprocess");
|
||
|
|
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||
|
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|