brackt/app/services/__tests__/sync-tournament-results.test.ts
Claude efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon:

1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/
   mirror windows scored via the placement-group path in processQualifyingEvent,
   which took the tie span from the players present on that window's roster
   (a draftable subset) instead of the full field. A window holding fewer than
   the 8 tied R16 losers split the 9-16 points too few ways and over-awarded.
   The tie span is now derived from the canonical tournament_results (the whole
   field) for tournament-linked events, falling back to the live count only for
   standalone events. Every league now scores identically.

2. Discord notifications rounded QP with Math.round, turning 1.5 into "2".
   Both the "Points Awarded" and "QP Standings" values now use a 2-decimal
   formatter mirroring the web UI's formatQP, so Discord and the site agree.

3. The Discord "QP Standings" block ranked players only among that event's
   scorers, showing two R16 losers as T1 instead of T9. Rank is now computed
   over the full season field and passed through to the notifier.

4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored
   counter incremented on every fan-out sync with a guard that misfired. It is
   now derived on read (getMajorsCompleted = count of completed qualifying
   events), which is self-correcting; the increment/decrement writes are removed
   along with the now-unused hasProcessedQualifyingPlacement helper.

Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so
already-corrupted seasons are corrected (2 -> 1.5), and threads a
skipNotifications option through processQualifyingEvent / syncTournamentResults
so the backfill does not re-ping every league.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 20:40:47 +00:00

888 lines
33 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as DrizzleOrm from "drizzle-orm";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/models/scoring-event", () => ({
completeScoringEvent: vi.fn(),
getScoringEventById: vi.fn(),
}));
vi.mock("~/models/event-result", () => ({
getEventResults: vi.fn(),
}));
vi.mock("~/models/tournament-result", () => ({
upsertTournamentResult: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
syncTournamentResults,
syncMajorFromPrimaryEvent,
} from "../sync-tournament-results";
import { database } from "~/database/context";
import {
processQualifyingEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
// ---------------------------------------------------------------------------
// Mock data types
// ---------------------------------------------------------------------------
interface FakeTournamentResult {
tournamentId: string;
participantId: string;
placement: number | null;
rawScore: string | null;
}
interface FakeScoringEvent {
id: string;
sportsSeasonId: string;
tournamentId: string | null;
name?: string | null;
}
interface FakeSeasonParticipant {
id: string;
sportsSeasonId: string;
participantId: string | null;
name: string;
}
interface FakeEventResult {
id: string;
scoringEventId: string;
seasonParticipantId: string;
placement: number | null;
qualifyingPointsAwarded: string | null;
rawScore: string | null;
}
// ---------------------------------------------------------------------------
// Fake in-memory DB
//
// We build a single shared object with a chainable select/insert/update API
// that the service uses identically whether it's the top-level `db` or the
// `tx` inside a transaction. This lets us both observe the writes that would
// have happened and emulate idempotent upserts.
//
// Internal state holds the three tables the service queries:
// - tournament_results
// - scoring_events
// - season_participants
// - event_results
// ---------------------------------------------------------------------------
interface FakeDbState {
tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[];
}
/**
* A very thin fake drizzle-ish db. The service only uses:
* - db.select().from(table).where(cond) -- returns rows
* - db.insert(table).values(row[|rows]) -- insert
* - db.update(table).set(...).where(cond) -- update
* - db.transaction(fn) -- runs fn with the same db
*
* We identify the target table by reference equality to the schema objects.
* The service passes schema.tournamentResults etc., so we capture the real
* schema module and compare by ref.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tableName(table: any): string {
// drizzle-orm PgTable exposes the table name under a symbol. Try a few.
const sym = Object.getOwnPropertySymbols(table).find(
(s) => s.toString() === "Symbol(drizzle:Name)"
);
if (sym) return table[sym];
// Fallback for our shim: read `.tableName` property if set.
return table.tableName;
}
function makeFakeDb(state: FakeDbState) {
// We mirror the shape in a lazy way: each predicate function is stored on the
// call so we can match rows. In practice the service only filters by simple
// eq() / and() combinations — we don't try to parse drizzle's SQL tree.
// Instead, we stash the filters the service applies by table.
//
// The simpler and more faithful approach: every call records enough info for
// us to return the right subset.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function rowsFor(table: any): any[] {
// Table objects are drizzle PgTable objects; we identify them by name.
const name = tableName(table);
switch (name) {
case "tournament_results":
return state.tournamentResults;
case "scoring_events":
return state.scoringEvents;
case "season_participants":
return state.seasonParticipants;
case "event_results":
return state.eventResults;
default:
throw new Error(`Unknown table in fake db: ${name}`);
}
}
const db = {
// ---------- select ----------
select: vi.fn().mockImplementation(() => {
const chain = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_table: null as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
from(table: any) {
chain._table = table;
return chain;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where(predicate: any) {
// The predicate is a drizzle SQL expression; we can't introspect it
// easily. The fake predicate stash sets a callback (see below). If
// the predicate is a function we apply it; otherwise we return all
// rows for the table (the caller will then filter).
const rows = rowsFor(chain._table);
if (typeof predicate === "function") {
return Promise.resolve(rows.filter(predicate));
}
// Unknown predicate: the test harness attaches `__fakeFilter` to the
// predicate value we interposed. When we can't evaluate, just return
// all rows — the fake is used only by the service and tests that
// control what rows exist.
return Promise.resolve(rows);
},
};
return chain;
}),
// ---------- insert ----------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
insert: vi.fn().mockImplementation((table: any) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values(row: any) {
const rows = Array.isArray(row) ? row : [row];
const existing = rowsFor(table);
for (const r of rows) {
// Assign a fake id for event_results (others aren't re-read by ID).
existing.push({ id: `er-${existing.length + 1}`, ...r });
}
return {
returning: vi.fn().mockResolvedValue(rows),
};
},
};
}),
// ---------- update ----------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: vi.fn().mockImplementation((table: any) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
set(patch: any) {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where(predicate: any) {
const rows = rowsFor(table);
for (const r of rows) {
if (typeof predicate === "function" ? predicate(r) : true) {
Object.assign(r, patch);
}
}
return Promise.resolve();
},
};
},
};
}),
// ---------- transaction ----------
transaction: vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => any) => fn(db)),
};
return db;
}
// ---------------------------------------------------------------------------
// The service builds drizzle SQL expressions via eq() / and() from
// drizzle-orm. We can't intercept those without re-mocking the whole module.
// Instead we mock drizzle-orm's eq/and to return predicate functions directly.
// ---------------------------------------------------------------------------
vi.mock("drizzle-orm", async () => {
const actual = await vi.importActual<typeof DrizzleOrm>("drizzle-orm");
return {
...actual,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eq: (col: any, val: any) => {
// Each column object (PgColumn) has a `.name` property giving the DB
// column name. Return a predicate that reads that key off the row.
const key = colKey(col);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (row: any) => row[key] === val;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function colKey(col: any): string {
// drizzle PgColumn: its .name is the snake_case DB name, but our fake state
// uses camelCase JS field names. Map common columns manually.
const n = col?.name ?? col?.config?.name ?? "";
const map: Record<string, string> = {
tournament_id: "tournamentId",
sports_season_id: "sportsSeasonId",
scoring_event_id: "scoringEventId",
season_participant_id: "seasonParticipantId",
participant_id: "participantId",
id: "id",
};
return map[n] ?? n;
}
// ---------------------------------------------------------------------------
// Helpers for building fixture data
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
});
function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
return {
tournamentResults: [],
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
...overrides,
};
}
// ---------------------------------------------------------------------------
// Test 1: Single window, full roster match
// ---------------------------------------------------------------------------
describe("syncTournamentResults", () => {
it("syncs a single window with two canonical results and fills 0-QP for unmatched", async () => {
const state = seedBasicState({
tournamentResults: [
{
tournamentId: "t-1",
participantId: "cp-A",
placement: 1,
rawScore: "280",
},
{
tournamentId: "t-1",
participantId: "cp-B",
placement: 2,
rawScore: "282",
},
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{
id: "sp-A",
sportsSeasonId: "ss-1",
participantId: "cp-A",
name: "A",
},
{
id: "sp-B",
sportsSeasonId: "ss-1",
participantId: "cp-B",
name: "B",
},
{
id: "sp-C",
sportsSeasonId: "ss-1",
participantId: "cp-C",
name: "C",
},
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(1);
expect(report.windowsFailed).toBe(0);
expect(report.failures).toEqual([]);
const eventRows = state.eventResults.filter(
(r) => r.scoringEventId === "ev-1"
);
// sp-A: placement 1, sp-B: placement 2, sp-C: 0-QP filler
expect(eventRows).toHaveLength(3);
const a = eventRows.find((r) => r.seasonParticipantId === "sp-A");
expect(a?.placement).toBe(1);
expect(a?.rawScore).toBe("280");
const b = eventRows.find((r) => r.seasonParticipantId === "sp-B");
expect(b?.placement).toBe(2);
expect(b?.rawScore).toBe("282");
const c = eventRows.find((r) => r.seasonParticipantId === "sp-C");
expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { skipNotifications: false });
});
// -------------------------------------------------------------------------
// Test 2: Two overlapping windows — same tournament, different sports_seasons
// -------------------------------------------------------------------------
it("fans out to multiple windows, each getting only its own participants", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-X", placement: 1, rawScore: null },
{ tournamentId: "t-1", participantId: "cp-Y", placement: 2, rawScore: null },
{ tournamentId: "t-1", participantId: "cp-Z", placement: 3, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
// Window A has X, Y
{ id: "sp-AX", sportsSeasonId: "ss-A", participantId: "cp-X", name: "X" },
{ id: "sp-AY", sportsSeasonId: "ss-A", participantId: "cp-Y", name: "Y" },
// Window B has Y, Z
{ id: "sp-BY", sportsSeasonId: "ss-B", participantId: "cp-Y", name: "Y" },
{ id: "sp-BZ", sportsSeasonId: "ss-B", participantId: "cp-Z", name: "Z" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(2);
expect(report.windowsFailed).toBe(0);
const aRows = state.eventResults.filter((r) => r.scoringEventId === "ev-A");
const bRows = state.eventResults.filter((r) => r.scoringEventId === "ev-B");
// Window A: sp-AX (placement 1), sp-AY (placement 2). No 0-QP fillers
// since both roster members got results.
expect(aRows).toHaveLength(2);
expect(aRows.find((r) => r.seasonParticipantId === "sp-AX")?.placement).toBe(1);
expect(aRows.find((r) => r.seasonParticipantId === "sp-AY")?.placement).toBe(2);
// Window B: sp-BY (placement 2), sp-BZ (placement 3). Again no fillers.
expect(bRows).toHaveLength(2);
expect(bRows.find((r) => r.seasonParticipantId === "sp-BY")?.placement).toBe(2);
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { skipNotifications: false });
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { skipNotifications: false });
});
// -------------------------------------------------------------------------
// Test 3: Idempotent re-run
// -------------------------------------------------------------------------
it("is idempotent: a second run leaves the same end state", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
const afterFirst = JSON.parse(JSON.stringify(state.eventResults));
await syncTournamentResults("t-1");
const afterSecond = state.eventResults;
// Same row count, same per-row placement / QP / participant / event.
expect(afterSecond).toHaveLength(afterFirst.length);
for (const first of afterFirst) {
const match = afterSecond.find(
(r) =>
r.scoringEventId === first.scoringEventId &&
r.seasonParticipantId === first.seasonParticipantId
);
expect(match).toBeDefined();
expect(match?.placement).toBe(first.placement);
expect(match?.qualifyingPointsAwarded).toBe(first.qualifyingPointsAwarded);
expect(match?.rawScore).toBe(first.rawScore);
}
});
// -------------------------------------------------------------------------
// Test 4: Correction propagation
// -------------------------------------------------------------------------
it("propagates canonical placement corrections to all linked windows", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
// Sanity — both windows have sp-* at placement 1.
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
).toBe(1);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
).toBe(1);
// Correct the canonical placement from 1 to 5.
state.tournamentResults[0].placement = 5;
state.tournamentResults[0].rawScore = "99";
await syncTournamentResults("t-1");
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
).toBe(5);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.rawScore
).toBe("99");
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
).toBe(5);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.rawScore
).toBe("99");
});
// -------------------------------------------------------------------------
// Test 5: Partial failure isolation
// -------------------------------------------------------------------------
it("isolates failures: one window's error does not stop the others", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
// First window throws in processQualifyingEvent; second succeeds.
vi.mocked(processQualifyingEvent)
.mockImplementationOnce(async () => {
throw new Error("QP calc failed");
})
.mockImplementationOnce(async () => undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(1);
expect(report.windowsFailed).toBe(1);
expect(report.failures).toHaveLength(1);
expect(report.failures[0].scoringEventId).toBe("ev-A");
expect(report.failures[0].sportsSeasonId).toBe("ss-A");
expect(report.failures[0].error).toContain("QP calc failed");
// Second window (ev-B) still has its row committed in our fake (we don't
// simulate rollback, but the service called through to the writes path,
// which is what we care about — the try/catch did not propagate).
expect(
state.eventResults.find(
(r) => r.scoringEventId === "ev-B" && r.seasonParticipantId === "sp-BA"
)
).toBeDefined();
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
});
// -------------------------------------------------------------------------
// Test 6: Participant in roster but not in canonical results → 0-QP filler
// -------------------------------------------------------------------------
it("writes 0-QP filler rows for roster participants absent from canonical results", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
{ id: "sp-C", sportsSeasonId: "ss-1", participantId: "cp-C", name: "C" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
const rows = state.eventResults;
expect(rows).toHaveLength(3);
const a = rows.find((r) => r.seasonParticipantId === "sp-A");
expect(a?.placement).toBe(1);
expect(a?.qualifyingPointsAwarded).toBeUndefined(); // not set by this service
const b = rows.find((r) => r.seasonParticipantId === "sp-B");
expect(b?.placement).toBeNull();
expect(b?.qualifyingPointsAwarded).toBe("0");
const c = rows.find((r) => r.seasonParticipantId === "sp-C");
expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0");
});
// -------------------------------------------------------------------------
// Test 7: Completion fan-out (the core "score once" fix)
// -------------------------------------------------------------------------
it("marks each synced window complete by default", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(completeScoringEvent).toHaveBeenCalledTimes(2);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-A", db);
expect(completeScoringEvent).toHaveBeenCalledWith("ev-B", db);
});
it("does NOT mark windows complete when markComplete is false", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1", { markComplete: false });
expect(completeScoringEvent).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Test 8: League recalculation per synced window
// -------------------------------------------------------------------------
it("recalculates affected leagues once per cleanly-synced window", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1", name: "Major A" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1", name: "Major B" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-A", undefined, {
eventId: "ev-A",
eventName: "Major A",
skipDiscord: false,
});
expect(recalculateAffectedLeagues).toHaveBeenCalledWith("ss-B", undefined, {
eventId: "ev-B",
eventName: "Major B",
skipDiscord: false,
});
});
// -------------------------------------------------------------------------
// Test 9: skipEventId excludes the primary window
// -------------------------------------------------------------------------
it("skips the primary window when skipEventId is given", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1", {
skipEventId: "ev-PRIMARY",
});
expect(report.windowsSynced).toBe(1);
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { skipNotifications: false });
// No event_results written for the skipped primary window.
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// syncMajorFromPrimaryEvent: bracket/CS2 majors promote primary results to
// canonical tournament_results, then fan out to siblings (primary skipped).
// ---------------------------------------------------------------------------
describe("syncMajorFromPrimaryEvent", () => {
it("promotes primary event_results to canonical and fans out to siblings", async () => {
const state = seedBasicState({
scoringEvents: [
{ id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Major" },
{ id: "ev-SIBLING", sportsSeasonId: "ss-S", tournamentId: "t-1", name: "Major" },
],
seasonParticipants: [
{ id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" },
{ id: "sp-PB", sportsSeasonId: "ss-P", participantId: "cp-B", name: "B" },
{ id: "sp-SA", sportsSeasonId: "ss-S", participantId: "cp-A", name: "A" },
{ id: "sp-SB", sportsSeasonId: "ss-S", participantId: "cp-B", name: "B" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-PRIMARY",
sportsSeasonId: "ss-P",
tournamentId: "t-1",
name: "Major",
} as never);
// Primary window's derived results: cp-A 1st, cp-B 2nd.
vi.mocked(getEventResults).mockResolvedValue([
{
placement: 1,
rawScore: "1",
notParticipating: false,
seasonParticipantId: "sp-PA",
seasonParticipant: { participantId: "cp-A" },
},
{
placement: 2,
rawScore: "2",
notParticipating: false,
seasonParticipantId: "sp-PB",
seasonParticipant: { participantId: "cp-B" },
},
] as never);
// upsertTournamentResult writes into our fake canonical table so the
// subsequent syncTournamentResults can read them back.
vi.mocked(upsertTournamentResult).mockImplementation(
async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => {
state.tournamentResults.push({
tournamentId: data.tournamentId,
participantId: data.participantId,
placement: data.placement ?? null,
rawScore: data.rawScore ?? null,
});
return data as never;
}
);
const report = await syncMajorFromPrimaryEvent("ev-PRIMARY");
// Two canonical results promoted from the primary.
expect(upsertTournamentResult).toHaveBeenCalledTimes(2);
expect(state.tournamentResults).toHaveLength(2);
// Only the sibling window is synced (primary is skipped).
expect(report.windowsSynced).toBe(1);
expect(
state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY")
).toBe(false);
const sib = state.eventResults.filter((r) => r.scoringEventId === "ev-SIBLING");
expect(sib.find((r) => r.seasonParticipantId === "sp-SA")?.placement).toBe(1);
expect(sib.find((r) => r.seasonParticipantId === "sp-SB")?.placement).toBe(2);
// In-progress fan-out: siblings are NOT marked complete by default.
expect(completeScoringEvent).not.toHaveBeenCalled();
});
it("throws when the primary event is not linked to a tournament", async () => {
const db = makeFakeDb(seedBasicState());
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(getScoringEventById).mockResolvedValue({
id: "ev-X",
sportsSeasonId: "ss-X",
tournamentId: null,
name: "Standalone",
} as never);
await expect(syncMajorFromPrimaryEvent("ev-X")).rejects.toThrow(
/not linked to a tournament/
);
});
});
// ---------------------------------------------------------------------------
// Regression: review fixes #1 (recalc failure counts) and #2 (stale removal)
// ---------------------------------------------------------------------------
describe("syncTournamentResults — recalc failure & stale removal", () => {
it("counts a league-recalc failure as a failed window (so status can't show completed)", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockRejectedValue(new Error("recalc boom"));
const report = await syncTournamentResults("t-1");
expect(report.windowsFailed).toBe(1);
expect(report.failures.some((f) => f.error.includes("League recalc failed"))).toBe(true);
});
it("resets a previously-placed participant to filler when dropped from canonical", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
],
scoringEvents: [{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" }],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
expect(state.eventResults.find((r) => r.seasonParticipantId === "sp-A")?.placement).toBe(1);
// Correction: cp-A is removed from the canonical results entirely.
state.tournamentResults.length = 0;
await syncTournamentResults("t-1");
const row = state.eventResults.find((r) => r.seasonParticipantId === "sp-A");
expect(row?.placement).toBeNull();
expect(row?.rawScore).toBeNull();
expect(row?.qualifyingPointsAwarded).toBe("0");
});
});