Compare commits
No commits in common. "main" and "claude/session-crx3tm" have entirely different histories.
main
...
claude/ses
12 changed files with 33 additions and 982 deletions
|
|
@ -1,26 +1,18 @@
|
||||||
import * as Sentry from "@sentry/react-router";
|
import * as Sentry from "@sentry/react-router";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { shouldReportServerError } from "~/lib/error-reporting";
|
|
||||||
|
|
||||||
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
|
import type { AppLoadContext, EntryContext } from "react-router";
|
||||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
import { ServerRouter } from "react-router";
|
import { ServerRouter } from "react-router";
|
||||||
import { isbot } from "isbot";
|
import { isbot } from "isbot";
|
||||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||||
import { renderToPipeableStream } from "react-dom/server";
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
|
||||||
const sentryHandleError = Sentry.createSentryHandleError({
|
export const handleError = Sentry.createSentryHandleError({
|
||||||
logErrors: true,
|
logErrors: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const handleError: HandleErrorFunction = (error, args) => {
|
|
||||||
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
|
|
||||||
// keeps them out of the `logErrors` console output; morgan still logs the request.
|
|
||||||
if (!shouldReportServerError(error, args.request)) return;
|
|
||||||
return sentryHandleError(error, args);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const streamTimeout = 5_000;
|
export const streamTimeout = 5_000;
|
||||||
|
|
||||||
async function handleRequest(
|
async function handleRequest(
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createStaticHandler } from "react-router";
|
|
||||||
import { shouldReportServerError } from "../error-reporting";
|
|
||||||
|
|
||||||
const ORIGIN = "https://brackt.com";
|
|
||||||
|
|
||||||
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
|
|
||||||
function routeError(
|
|
||||||
status: number,
|
|
||||||
internal: boolean,
|
|
||||||
statusText = "Not Found",
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
statusText,
|
|
||||||
internal,
|
|
||||||
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function request(path: string, referer?: string, method = "GET") {
|
|
||||||
return new Request(`${ORIGIN}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: referer ? { referer } : {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("shouldReportServerError", () => {
|
|
||||||
it("drops a router 404 for a scanner hitting a URL cold", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/wp/v2/posts/999999"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a router 404 linked from another site", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/", "https://evil.example/"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a router 404 linked from one of our own pages", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/leagues/gone", `${ORIGIN}/leagues`),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the 405 from a POST to a route with no action", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(405, true, "Method Not Allowed"),
|
|
||||||
request("/", undefined, "POST"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a 404 the app threw deliberately", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, false),
|
|
||||||
request("/leagues/missing"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a 403 the app threw from an ownership check", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(403, false, "Forbidden"),
|
|
||||||
request("/admin/sports"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a router-internal 500", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(500, true, "Internal Server Error"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a plain exception", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(new Error("boom"), request("/leagues")),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports anything that is not a route error response", () => {
|
|
||||||
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a router 404 whose referer header is not a URL", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/blog/", "not a url"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The unit tests above use hand-written error objects. These drive real requests
|
|
||||||
* through React Router so the suite fails if the shape it throws ever changes.
|
|
||||||
*/
|
|
||||||
describe("shouldReportServerError against real React Router errors", () => {
|
|
||||||
const handler = createStaticHandler([
|
|
||||||
{
|
|
||||||
id: "root",
|
|
||||||
path: "/",
|
|
||||||
children: [{ id: "home", index: true, loader: () => null }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function errorFor(req: Request) {
|
|
||||||
const ctx = await handler.query(req);
|
|
||||||
if (ctx instanceof Response) return null;
|
|
||||||
return Object.values(ctx.errors ?? {})[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
|
|
||||||
const req = request("/blog/wp/v2/posts/999999");
|
|
||||||
const error = await errorFor(req);
|
|
||||||
expect(error).toMatchObject({ status: 404, internal: true });
|
|
||||||
expect(shouldReportServerError(error, req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the same 404 when it came from a link on our own site", async () => {
|
|
||||||
const req = request("/nope", `${ORIGIN}/leagues`);
|
|
||||||
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the 405 from a POST to a route with no action", async () => {
|
|
||||||
const req = request("/", undefined, "POST");
|
|
||||||
const error = await errorFor(req);
|
|
||||||
expect(error).toMatchObject({ status: 405, internal: true });
|
|
||||||
expect(shouldReportServerError(error, req)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("static asset 404s", () => {
|
|
||||||
it("drops a stale hashed bundle even with a same-host referer", () => {
|
|
||||||
// Every deploy leaves clients requesting the previous build's assets.
|
|
||||||
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"/assets/app-x1.css",
|
|
||||||
"/fonts/inter.woff2",
|
|
||||||
"/images/logo.png",
|
|
||||||
"/favicon.ico",
|
|
||||||
])("drops a 404 for %s", (path) => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request(path, `${ORIGIN}/`),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still follows the referer rule for a non-asset path containing a dot", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(404, true),
|
|
||||||
request("/leagues/v1.2", `${ORIGIN}/leagues`),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("React Router internal statuses that are not 404/405", () => {
|
|
||||||
it("reports an internal 400 (route is missing a loader)", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(400, true, "Bad Request"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an internal 403 (route does not match URL)", () => {
|
|
||||||
expect(
|
|
||||||
shouldReportServerError(
|
|
||||||
routeError(403, true, "Forbidden"),
|
|
||||||
request("/leagues"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("production shape: TLS terminated upstream", () => {
|
|
||||||
it("reports a 404 linked from our own site when the proxy strips https", () => {
|
|
||||||
// Express builds request.url from req.protocol, which is `http` inside the
|
|
||||||
// container. Real browsers send an https referer. Comparing full origins
|
|
||||||
// would never match, silencing every broken internal link.
|
|
||||||
const req = new Request("http://brackt.com/leagues/gone", {
|
|
||||||
headers: { referer: "https://brackt.com/leagues" },
|
|
||||||
});
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still drops a cold scanner hit under that same shape", () => {
|
|
||||||
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still drops a 404 linked from another site under that same shape", () => {
|
|
||||||
const req = new Request("http://brackt.com/nope", {
|
|
||||||
headers: { referer: "https://evil.example/" },
|
|
||||||
});
|
|
||||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* Decides which server-side errors are worth sending to Sentry.
|
|
||||||
*
|
|
||||||
* Automated scanners probe for CMS paths that have never existed here
|
|
||||||
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
|
|
||||||
* throws for each one — a 404 when no route matches, a 405 when a route has no
|
|
||||||
* `action` — and every throw reaches `handleError` in `app/entry.server.tsx`.
|
|
||||||
* Reporting those burns the Sentry quota without ever describing a real bug.
|
|
||||||
*/
|
|
||||||
import { isRouteErrorResponse } from "react-router";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Statuses React Router uses to say "nothing here matched this request":
|
|
||||||
* 404 when no route matches the URL, 405 when the route has no `action` or the
|
|
||||||
* method is invalid. Its other internal statuses (400 "did not provide a
|
|
||||||
* `loader`", 403 "Route does not match URL") describe a misconfigured route
|
|
||||||
* rather than an unrecognised request, so those keep reporting.
|
|
||||||
*/
|
|
||||||
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static assets 404 in bulk for reasons that are never actionable: scanners
|
|
||||||
* guessing filenames, and clients running stale HTML that still references the
|
|
||||||
* previous deploy's hashed bundles.
|
|
||||||
*/
|
|
||||||
const ASSET_EXT_RE =
|
|
||||||
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
|
|
||||||
|
|
||||||
/** React Router stamps `internal: true` on the errors it generates itself. */
|
|
||||||
function isInternalRouterError(error: unknown): boolean {
|
|
||||||
return (error as { internal?: unknown }).internal === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the request was linked from a page on this same site.
|
|
||||||
*
|
|
||||||
* Compares host rather than origin on purpose. Production terminates TLS
|
|
||||||
* upstream and serves plain HTTP in the container, so `request.url` — which
|
|
||||||
* `@react-router/express` builds from `req.protocol` — says `http` while the
|
|
||||||
* browser sends an `https` referer. Comparing full origins would therefore
|
|
||||||
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
|
|
||||||
* around the same mismatch for invite URLs.) Protocol tells us nothing about
|
|
||||||
* whether the link was ours; host does.
|
|
||||||
*/
|
|
||||||
function hasSameHostReferer(request: Request): boolean {
|
|
||||||
const referer = request.headers.get("referer");
|
|
||||||
if (!referer) return false;
|
|
||||||
try {
|
|
||||||
return new URL(referer).host === new URL(request.url).host;
|
|
||||||
} catch {
|
|
||||||
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether `error` should be reported to Sentry.
|
|
||||||
*
|
|
||||||
* Drops the 404s and 405s React Router generated for a request that matched
|
|
||||||
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
|
|
||||||
* other internal statuses, and responses the app threw deliberately
|
|
||||||
* (`internal: false`), so a 403 from an ownership check still shows up.
|
|
||||||
*
|
|
||||||
* The exception is a request carrying a same-host `Referer`: a 404 reached from
|
|
||||||
* one of our own pages is a broken internal link, not a scanner, and stays
|
|
||||||
* visible in Sentry. Asset paths are excluded from that exception — a stale
|
|
||||||
* client requesting last deploy's bundle sends a same-host referer too, and
|
|
||||||
* would otherwise spike Sentry on every release.
|
|
||||||
*/
|
|
||||||
export function shouldReportServerError(
|
|
||||||
error: unknown,
|
|
||||||
request: Request,
|
|
||||||
): boolean {
|
|
||||||
if (!isRouteErrorResponse(error)) return true;
|
|
||||||
if (!isInternalRouterError(error)) return true;
|
|
||||||
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
|
|
||||||
|
|
||||||
let pathname: string;
|
|
||||||
try {
|
|
||||||
pathname = new URL(request.url).pathname;
|
|
||||||
} catch {
|
|
||||||
pathname = "";
|
|
||||||
}
|
|
||||||
if (ASSET_EXT_RE.test(pathname)) return false;
|
|
||||||
|
|
||||||
return hasSameHostReferer(request);
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
/**
|
|
||||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
|
||||||
*
|
|
||||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
|
||||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
|
||||||
* Qualifying Final loser cannot meet the side that just beat it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { AFL_10 } from "~/lib/bracket-templates";
|
|
||||||
|
|
||||||
interface MatchRow {
|
|
||||||
id: string;
|
|
||||||
scoringEventId: string;
|
|
||||||
round: string;
|
|
||||||
matchNumber: number;
|
|
||||||
participant1Id: string | null;
|
|
||||||
participant2Id: string | null;
|
|
||||||
isComplete: boolean;
|
|
||||||
winnerId: string | null;
|
|
||||||
loserId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: MatchRow[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
|
||||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
|
||||||
*/
|
|
||||||
function whereValues(node: unknown, depth = 0): string[] {
|
|
||||||
if (!node || depth > 10) return [];
|
|
||||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
|
||||||
if (typeof node !== "object") return [];
|
|
||||||
const obj = node as Record<string, unknown>;
|
|
||||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
|
||||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = {
|
|
||||||
query: {
|
|
||||||
playoffMatches: {
|
|
||||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
|
||||||
}),
|
|
||||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
return Promise.resolve(
|
|
||||||
rows
|
|
||||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: vi.fn(() => ({
|
|
||||||
set: (data: Partial<MatchRow>) => {
|
|
||||||
const applyTo = (where: unknown) => {
|
|
||||||
const values = whereValues(where);
|
|
||||||
const target = rows.find((r) => values.includes(r.id));
|
|
||||||
if (target) Object.assign(target, data);
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
// Advancement writes through the query builder with and without .returning().
|
|
||||||
return {
|
|
||||||
where: (where: unknown) => {
|
|
||||||
const applied = Promise.resolve([applyTo(where)]);
|
|
||||||
return Object.assign(applied, { returning: () => applied });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
// No rollback: the tests assert the writes that were attempted, in order.
|
|
||||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
|
||||||
|
|
||||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
|
||||||
|
|
||||||
const EVENT = "event-1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
|
||||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
|
||||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
|
||||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
|
||||||
*/
|
|
||||||
const FREO = "fremantle";
|
|
||||||
const SYDNEY = "sydney";
|
|
||||||
const BRISBANE = "brisbane";
|
|
||||||
const HAWTHORN = "hawthorn";
|
|
||||||
const GEELONG = "geelong";
|
|
||||||
const ADELAIDE = "adelaide";
|
|
||||||
const BULLDOGS = "bulldogs";
|
|
||||||
const CARLTON = "carlton";
|
|
||||||
|
|
||||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
|
||||||
function bracket(): MatchRow[] {
|
|
||||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
|
||||||
return [
|
|
||||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
|
||||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
|
||||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
|
||||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
|
||||||
// Filled by the Qualifying Final losers, as advancement already does.
|
|
||||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
|
||||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
|
||||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
|
||||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(id: string): MatchRow {
|
|
||||||
const found = rows.find((r) => r.id === id);
|
|
||||||
if (!found) throw new Error(`No such match ${id}`);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record a result the way setMatchWinner does, then advance it. */
|
|
||||||
async function win(id: string, winnerId: string) {
|
|
||||||
const match = row(id);
|
|
||||||
match.winnerId = winnerId;
|
|
||||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
|
||||||
match.isComplete = true;
|
|
||||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairing = () => ({
|
|
||||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
|
||||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
rows = bracket();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
|
||||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
|
|
||||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
|
||||||
expect(row("sf1").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
await win("ef2", ADELAIDE);
|
|
||||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
|
||||||
await win("sf1", FREO);
|
|
||||||
|
|
||||||
expect(row("pf2").participant2Id).toBe(FREO);
|
|
||||||
expect(row("pf1").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
|
||||||
await win("ef1", GEELONG);
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
|
|
||||||
await win("ef1", CARLTON);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reseedAflSemiFinals", () => {
|
|
||||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
|
||||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
|
||||||
row("sf1").participant2Id = ADELAIDE;
|
|
||||||
row("sf2").participant2Id = GEELONG;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(pairing()).toEqual({
|
|
||||||
sf1: [FREO, GEELONG],
|
|
||||||
sf2: [BRISBANE, ADELAIDE],
|
|
||||||
});
|
|
||||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
|
||||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
|
||||||
{ matchNumber: 1, participantId: GEELONG },
|
|
||||||
{ matchNumber: 2, participantId: ADELAIDE },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes nothing when the pairings are already right", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
|
||||||
row("sf1").participant2Id = GEELONG;
|
|
||||||
row("sf2").participant2Id = ADELAIDE;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
|
||||||
expect(db.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
|
|
||||||
await reseedAflSemiFinals(EVENT);
|
|
||||||
|
|
||||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
|
||||||
expect(row("sf2").participant2Id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
row("sf1").participant2Id = SYDNEY;
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
|
||||||
Object.assign(row("sf1"), {
|
|
||||||
participant2Id: ADELAIDE,
|
|
||||||
isComplete: true,
|
|
||||||
winnerId: FREO,
|
|
||||||
loserId: ADELAIDE,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"Semi-Finals match 1 already has a recorded result"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
|
||||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"Elimination Finals match 1 winner is not one of its participants"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
|
||||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
|
||||||
|
|
||||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
|
||||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -748,9 +748,8 @@ async function generateNFL14Bracket(
|
||||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||||
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
* - Semi-Finals: QF losers vs EF winners
|
||||||
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
* - Preliminary Finals: QF winners vs SF winners
|
||||||
* (the crossover keeps a QF loser away from the side that just beat it)
|
|
||||||
* - Grand Final: PF winners
|
* - Grand Final: PF winners
|
||||||
*/
|
*/
|
||||||
async function generateAFL10Bracket(
|
async function generateAFL10Bracket(
|
||||||
|
|
@ -825,7 +824,7 @@ async function generateAFL10Bracket(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
|
|
@ -994,134 +993,15 @@ export async function reseedAflEliminationFinals(
|
||||||
return reseed;
|
return reseed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
|
||||||
export interface AflSemiFinalReseed {
|
|
||||||
vacated: number[];
|
|
||||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
|
||||||
*
|
|
||||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
|
||||||
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
|
||||||
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
|
||||||
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
|
||||||
*
|
|
||||||
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
|
||||||
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
|
||||||
* results recorded so far rather than writing the one it was called for: a winner sitting
|
|
||||||
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
|
||||||
* beaten team back out instead of leaving it alive.
|
|
||||||
*
|
|
||||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
|
||||||
* while advancing a match can predate the winner being written to it.
|
|
||||||
*
|
|
||||||
* Idempotent: pairings that are already right do no writes.
|
|
||||||
*/
|
|
||||||
export async function reseedAflSemiFinals(
|
|
||||||
eventId: string,
|
|
||||||
pending?: { matchId: string; winnerId: string }
|
|
||||||
): Promise<AflSemiFinalReseed> {
|
|
||||||
const [efMatches, sfMatches] = await Promise.all([
|
|
||||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
|
||||||
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
|
||||||
if (efMatches.length === 0 || sfMatches.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
|
||||||
// depends on the other game.
|
|
||||||
const wanted = new Map<number, string>();
|
|
||||||
for (const ef of efMatches) {
|
|
||||||
const decidedWinner =
|
|
||||||
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
|
||||||
if (!decidedWinner) continue;
|
|
||||||
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
|
||||||
throw new Error(
|
|
||||||
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
wanted.set(ef.matchNumber, decidedWinner);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
|
||||||
// in a slot came from somewhere this function knows nothing about.
|
|
||||||
const eliminationParticipants = new Set<string>();
|
|
||||||
for (const ef of efMatches) {
|
|
||||||
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
|
||||||
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
|
||||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
|
||||||
|
|
||||||
for (const sfMatch of sfMatches) {
|
|
||||||
const occupant = sfMatch.participant2Id;
|
|
||||||
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
|
||||||
if (occupant === belongsHere) continue;
|
|
||||||
|
|
||||||
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
|
||||||
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
|
|
||||||
}
|
|
||||||
// Re-seeding a game that has already been played would rewrite who contested a
|
|
||||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
|
||||||
// than quietly corrupting the bracket.
|
|
||||||
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
|
|
||||||
throw new Error(
|
|
||||||
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
|
||||||
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
|
||||||
if (belongsHere !== null) {
|
|
||||||
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reseed: AflSemiFinalReseed = {
|
|
||||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
|
||||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
|
||||||
|
|
||||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
|
||||||
// same team in both Semi-Finals.
|
|
||||||
const db = database();
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const now = new Date();
|
|
||||||
for (const slot of slotsToClear) {
|
|
||||||
await tx
|
|
||||||
.update(schema.playoffMatches)
|
|
||||||
.set({ participant2Id: null, updatedAt: now })
|
|
||||||
.where(eq(schema.playoffMatches.id, slot.id));
|
|
||||||
}
|
|
||||||
for (const slot of slotsToFill) {
|
|
||||||
await tx
|
|
||||||
.update(schema.playoffMatches)
|
|
||||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
|
||||||
.where(eq(schema.playoffMatches.id, slot.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return reseed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AFL-specific advancement logic for the complex double-chance system
|
* AFL-specific advancement logic for the complex double-chance system
|
||||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||||
*
|
*
|
||||||
* Advancement rules:
|
* Advancement rules:
|
||||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
* - Elimination Finals: Winner → Semi-Finals
|
||||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
* - Semi-Finals: Winner → Preliminary Finals
|
||||||
* - Preliminary Finals: Winner → Grand Final
|
* - Preliminary Finals: Winner → Grand Final
|
||||||
*/
|
*/
|
||||||
async function advanceAFLWinner(
|
async function advanceAFLWinner(
|
||||||
|
|
@ -1162,11 +1042,18 @@ async function advanceAFLWinner(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
// Elimination Finals: Winner → Semi-Finals
|
||||||
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
|
||||||
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
|
||||||
if (match.round === "Elimination Finals") {
|
if (match.round === "Elimination Finals") {
|
||||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
// EF Match 1 winner → SF2 participant2
|
||||||
|
// EF Match 2 winner → SF1 participant2
|
||||||
|
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||||
|
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||||
|
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||||
|
|
||||||
|
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||||
|
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||||
|
|
||||||
|
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
/**
|
|
||||||
* The Fix Semi-Final Pairings admin action.
|
|
||||||
*
|
|
||||||
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
|
|
||||||
* crossed the two winners, and nothing re-runs advancement — a completed match cannot be
|
|
||||||
* re-submitted from the UI.
|
|
||||||
*
|
|
||||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { reseedAflSemiFinals } from "~/models/playoff-match";
|
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
||||||
import { getScoringEventById } from "~/models/scoring-event";
|
|
||||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
|
||||||
import { sendDiscordWebhook } from "~/services/discord";
|
|
||||||
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(),
|
|
||||||
isReadOnlySibling: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
reseedAflSemiFinals: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
findParticipantsBySportsSeasonId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
processMatchResult: vi.fn(),
|
|
||||||
recalculateAffectedLeagues: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<object>()),
|
|
||||||
sendDiscordWebhook: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const params = { id: "season-1", eventId: "event-1" };
|
|
||||||
|
|
||||||
const EVENT = {
|
|
||||||
id: "event-1",
|
|
||||||
name: "AFL Finals",
|
|
||||||
sportsSeasonId: "season-1",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
bracketTemplateId: "afl_10",
|
|
||||||
};
|
|
||||||
|
|
||||||
function request() {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("intent", "reseed-afl-semifinals");
|
|
||||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = () => action({ request: request(), params } as never);
|
|
||||||
|
|
||||||
describe("reseed-afl-semifinals", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
|
||||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
|
||||||
{ id: "geelong", name: "Geelong Cats" },
|
|
||||||
{ id: "adelaide", name: "Adelaide Crows" },
|
|
||||||
] as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the teams that moved", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [
|
|
||||||
{ matchNumber: 2, participantId: "adelaide" },
|
|
||||||
{ matchNumber: 1, participantId: "geelong" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await run();
|
|
||||||
|
|
||||||
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
|
|
||||||
expect(result).toEqual({
|
|
||||||
success:
|
|
||||||
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
|
|
||||||
"match 2 now hosts Adelaide Crows.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a slot that was emptied without being refilled", async () => {
|
|
||||||
// Un-recording an Elimination Final result takes its winner back out of the semi.
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [{ matchNumber: 2, participantId: "adelaide" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
success:
|
|
||||||
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
|
|
||||||
"match 2 now hosts Adelaide Crows.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says so when the pairings are already right", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scores nothing and announces nothing", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
|
||||||
vacated: [1, 2],
|
|
||||||
filled: [{ matchNumber: 1, participantId: "geelong" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await run();
|
|
||||||
|
|
||||||
expect(processMatchResult).not.toHaveBeenCalled();
|
|
||||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
|
||||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
|
||||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
|
||||||
...EVENT,
|
|
||||||
bracketTemplateId: "nfl_14",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "This action only applies to AFL finals brackets",
|
|
||||||
});
|
|
||||||
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
|
||||||
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
|
|
||||||
new Error("Semi-Finals match 1 already has a recorded result")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await run()).toEqual({
|
|
||||||
error: "Semi-Finals match 1 already has a recorded result",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
assignParticipantsToKnockout,
|
assignParticipantsToKnockout,
|
||||||
doesLoserAdvance,
|
doesLoserAdvance,
|
||||||
reseedAflEliminationFinals,
|
reseedAflEliminationFinals,
|
||||||
reseedAflSemiFinals,
|
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import {
|
import {
|
||||||
createGame,
|
createGame,
|
||||||
|
|
@ -910,59 +909,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
|
||||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
|
||||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
|
||||||
// re-submitted).
|
|
||||||
if (intent === "reseed-afl-semifinals") {
|
|
||||||
try {
|
|
||||||
const event = await getScoringEventById(params.eventId);
|
|
||||||
if (!event) return { error: "Event not found" };
|
|
||||||
if (event.bracketTemplateId !== "afl_10") {
|
|
||||||
return { error: "This action only applies to AFL finals brackets" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
||||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
|
||||||
|
|
||||||
const reseed = await reseedAflSemiFinals(params.eventId);
|
|
||||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
|
||||||
return {
|
|
||||||
success:
|
|
||||||
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
|
||||||
// score or elimination changes, and so nothing to announce.
|
|
||||||
//
|
|
||||||
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
|
||||||
// result takes its winner back out — so report those too rather than rendering an
|
|
||||||
// empty list.
|
|
||||||
const filled = reseed.filled.map((slot) => ({
|
|
||||||
matchNumber: slot.matchNumber,
|
|
||||||
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
|
||||||
}));
|
|
||||||
const emptied = reseed.vacated
|
|
||||||
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
|
||||||
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
|
||||||
const moves = [...filled, ...emptied]
|
|
||||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
|
||||||
.map((move) => move.text)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: `Re-seeded the Semi-Finals: ${moves}.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
|
||||||
return {
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "reprocess-bracket") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
|
||||||
|
|
@ -638,31 +638,6 @@ export default function EventBracket({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
|
|
||||||
brackets advanced before that was fixed crossed the two winners, and no
|
|
||||||
admin action re-runs advancement. */}
|
|
||||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Fix Semi-Final Pairings</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Feed each Elimination Final into the Semi-Final it belongs to: EF1
|
|
||||||
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
|
|
||||||
— no results, scores or placements change, and nothing is announced.
|
|
||||||
Does nothing if the pairings are already right.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form method="post">
|
|
||||||
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
|
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
Fix Semi-Final Pairings
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
||||||
can rewrite a match's participants, so a wrong seeding has to be torn down
|
can rewrite a match's participants, so a wrong seeding has to be torn down
|
||||||
and rebuilt via the setup form below, which reappears once this runs. */}
|
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,7 @@ describe("readAflBracketSeeds", () => {
|
||||||
|
|
||||||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("simAFLFinals bracket pathways", () => {
|
describe("simAFLFinals Elimination Finals re-seeding", () => {
|
||||||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||||||
id: `s${i + 1}`,
|
id: `s${i + 1}`,
|
||||||
name: `s${i + 1}`,
|
name: `s${i + 1}`,
|
||||||
|
|
@ -682,20 +682,4 @@ describe("simAFLFinals bracket pathways", () => {
|
||||||
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
|
|
||||||
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
|
|
||||||
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
|
|
||||||
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
|
|
||||||
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
|
|
||||||
const pairings = pairingsWith("s7", "s8");
|
|
||||||
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
|
|
||||||
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
|
|
||||||
const pairings = pairingsWith("s7", "s8");
|
|
||||||
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
|
|
||||||
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
* losers → Semi-Finals (2nd chance)
|
* losers → Semi-Finals (2nd chance)
|
||||||
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
* Elimination Finals: #5 vs lower WC winner, → losers exit (7th/8th)
|
||||||
* #6 vs higher WC winner
|
* #6 vs higher WC winner
|
||||||
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → losers exit (5th/6th)
|
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th)
|
||||||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||||
*
|
*
|
||||||
|
|
@ -379,7 +379,7 @@ export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number
|
||||||
*
|
*
|
||||||
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||||
* recorded result is looked up against the game it was actually played in:
|
* recorded result is looked up against the game it was actually played in:
|
||||||
* SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner,
|
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
|
||||||
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||||
*
|
*
|
||||||
* Returns the placement for each team:
|
* Returns the placement for each team:
|
||||||
|
|
@ -419,10 +419,9 @@ export function simAFLFinals(
|
||||||
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
||||||
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
||||||
|
|
||||||
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
|
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||||
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
|
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
|
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
||||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.winner);
|
|
||||||
|
|
||||||
// Preliminary Finals: QF winners vs SF winners
|
// Preliminary Finals: QF winners vs SF winners
|
||||||
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ Sentry.init({
|
||||||
enabled: process.env.NODE_ENV === "production",
|
enabled: process.env.NODE_ENV === "production",
|
||||||
sendDefaultPii: true,
|
sendDefaultPii: true,
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: 0,
|
||||||
|
ignoreErrors: [
|
||||||
|
/No route matches URL ".*\.css"/,
|
||||||
|
/No route matches URL ".*\.js"/,
|
||||||
|
/No route matches URL ".*\.(php|env|xml|aspx|asp|bak|sql|ini)"/i,
|
||||||
|
/No route matches URL ".*\/(wp-admin|wp-login|phpmyadmin|xmlrpc)"/i,
|
||||||
|
],
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
const msg = event.exception?.values?.[0]?.value ?? "";
|
const msg = event.exception?.values?.[0]?.value ?? "";
|
||||||
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)
|
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,9 @@ export const app = express();
|
||||||
|
|
||||||
app.use((_, __, next) => DatabaseContext.run(db, next));
|
app.use((_, __, next) => DatabaseContext.run(db, next));
|
||||||
|
|
||||||
// Block common bot probe paths before React Router (and Sentry) see them.
|
// Block common bot probe paths before React Router (and Sentry) see them
|
||||||
// `blog` is here only because scanners hammer /blog/wp/v2/* — drop it from this
|
|
||||||
// list if a real blog route is ever added.
|
|
||||||
const BOT_PROBE_RE =
|
const BOT_PROBE_RE =
|
||||||
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator|blog)(\/|$)/i;
|
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator)(\/|$)/i;
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (BOT_PROBE_RE.test(req.path)) {
|
if (BOT_PROBE_RE.test(req.path)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue