brackt/app/routes/__tests__/admin.sports-seasons.bracket.reprocess.test.ts

209 lines
7.7 KiB
TypeScript
Raw Permalink Normal View History

Fix three defects found reviewing the bracket entry-floor work All three predate the EV fix on this branch and were surfaced by a review of the full main..HEAD range. 1. reprocess-bracket skipped its wipe exactly when it was needed. The wipe was guarded on `completed.length > 0`, but clear-bracket deliberately leaves placements alone and tells the admin to "Run Reprocess Bracket after rebuilding to clear the placements those results produced". After clear then regenerate nothing is completed, so the wipe was skipped and the discarded bracket's finalized placements survived — and upsertParticipantResult's never-un-finalize guard then stopped the entry floors and the replay from correcting them. The advertised recovery path could not work. The guard was not arbitrary: seasonParticipantResults is keyed by sports season, not by event, so a season-wide delete takes every other event's placements with it. Rather than flip the condition, narrow the delete. New deleteParticipantResultsForParticipants scopes it to the participants the bracket actually holds, which removes the collateral damage the guard was defending against, so the delete can run unconditionally. The participant set was already being computed further down for the elimination pass; it is now built once and reused. The qualifying branch keeps its season-wide delete, which is deliberate and rebuilds via finalizeQualifyingPoints. 2. Banked entry floors could miss teamStandings.totalPoints. generate-bracket recalculated standings only when `toEliminate` was empty, assuming markEliminatedAndAnnounce covers every other case. It does not — it recalculates only when the event is non-qualifying AND somebody was *newly* eliminated, i.e. had no prior result row. So the second run of a generation (the first wrote 0 for every non-bracket participant) recalculated nowhere, and neither did a qualifying event with teams to eliminate. The floors never reached the standings. markEliminatedAndAnnounce now returns { markedCount, recalculated } and the caller drives off that fact instead of re-deriving it, which also covers the case where the announcement threw — the catch swallows the error, and a failed recalc is precisely when the fallback should run. 3. The NBA mobile pager fell back to index geometry. Its BracketTreePaginated was the only one of five call sites not forwarding feeders/template, so mobile rendered "TBD" where desktop rendered "Winner of ...". Tests: reprocess wipes on a bracket with nothing played, stays scoped to the bracket, dedupes and skips empty slots, and leaves the qualifying path alone; generate recalculates in each of the four gaps above and still does not double-recalculate; and the NBA layout gives its mobile pane the same slot labels as desktop. Each was confirmed to fail against the previous behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 17:26:23 +00:00
/**
* 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();
});
});