1061 lines
39 KiB
TypeScript
1061 lines
39 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import type { Mock } from "vitest";
|
||
import type * as DrizzleOrm from "drizzle-orm";
|
||
import type * as ScoringCalculatorModule from "~/models/scoring-calculator";
|
||
|
||
vi.mock("~/database/context", () => ({
|
||
database: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("~/models/scoring-calculator", async () => {
|
||
// Keep the real implementations of the PURE helpers so the tie-count map handed
|
||
// to processQualifyingEvent reflects the mock canonical results AND the bracket's
|
||
// structural tie span (deriveBracketQualifyingStates / getRoundConfig). Only the
|
||
// DB-touching orchestrators are stubbed.
|
||
const actual = await vi.importActual<typeof ScoringCalculatorModule>(
|
||
"~/models/scoring-calculator"
|
||
);
|
||
return {
|
||
processQualifyingEvent: vi.fn(),
|
||
recalculateAffectedLeagues: vi.fn(),
|
||
buildTieCountByPlacement: actual.buildTieCountByPlacement,
|
||
deriveBracketQualifyingStates: actual.deriveBracketQualifyingStates,
|
||
getRoundConfig: actual.getRoundConfig,
|
||
};
|
||
});
|
||
|
||
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 FakePlayoffMatch {
|
||
scoringEventId: string;
|
||
round: string;
|
||
winnerId: string | null;
|
||
loserId: string | null;
|
||
participant1Id: string | null;
|
||
participant2Id: string | null;
|
||
}
|
||
|
||
interface FakeDbState {
|
||
tournamentResults: FakeTournamentResult[];
|
||
scoringEvents: FakeScoringEvent[];
|
||
seasonParticipants: FakeSeasonParticipant[];
|
||
eventResults: FakeEventResult[];
|
||
playoffMatches: FakePlayoffMatch[];
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
case "playoff_matches":
|
||
return state.playoffMatches;
|
||
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: [],
|
||
playoffMatches: [],
|
||
...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,
|
||
canonicalTieCountByPlacement: expect.any(Map),
|
||
});
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 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,
|
||
canonicalTieCountByPlacement: expect.any(Map),
|
||
});
|
||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, {
|
||
skipNotifications: false,
|
||
canonicalTieCountByPlacement: expect.any(Map),
|
||
});
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 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,
|
||
canonicalTieCountByPlacement: expect.any(Map),
|
||
});
|
||
// 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("splits mirror QP by the bracket's structural tie span, not the canonical row count (R16 in progress)", async () => {
|
||
// Tennis R16 in progress: two R16 matches are decided (losers cp-L1/cp-L2 land
|
||
// final at 9th) and two players (cp-F1/cp-F2) won their R32 match and are still
|
||
// "floored" at the R16 tier (also placement 9). So only FOUR players sit at
|
||
// placement 9 right now — the canonical row-count is 4, which would wrongly
|
||
// split 9th–16th four ways (→ 2 QP). The R16 tier structurally spans 8 slots,
|
||
// so every window must split (2+2+2+2+1+1+1+1)/8 = 1.5. This asserts the map
|
||
// handed to each mirror's processQualifyingEvent carries the structural span (8),
|
||
// not the live count (4).
|
||
const state = seedBasicState({
|
||
scoringEvents: [
|
||
{
|
||
id: "ev-PRIMARY",
|
||
sportsSeasonId: "ss-P",
|
||
tournamentId: "t-1",
|
||
name: "Wimbledon",
|
||
},
|
||
{
|
||
id: "ev-MIRROR",
|
||
sportsSeasonId: "ss-M",
|
||
tournamentId: "t-1",
|
||
name: "Wimbledon",
|
||
},
|
||
],
|
||
seasonParticipants: [
|
||
{ id: "sp-ML1", sportsSeasonId: "ss-M", participantId: "cp-L1", name: "L1" },
|
||
{ id: "sp-MF1", sportsSeasonId: "ss-M", participantId: "cp-F1", name: "F1" },
|
||
],
|
||
// Primary bracket state. R32 wins establish the "floored at R16" players;
|
||
// decided R16 matches establish the final 9th-place losers and the QF-floored
|
||
// winners (placement 5).
|
||
playoffMatches: [
|
||
{
|
||
scoringEventId: "ev-PRIMARY",
|
||
round: "Round of 32",
|
||
winnerId: "cp-F1",
|
||
loserId: "cp-out1",
|
||
participant1Id: "cp-F1",
|
||
participant2Id: "cp-out1",
|
||
},
|
||
{
|
||
scoringEventId: "ev-PRIMARY",
|
||
round: "Round of 32",
|
||
winnerId: "cp-F2",
|
||
loserId: "cp-out2",
|
||
participant1Id: "cp-F2",
|
||
participant2Id: "cp-out2",
|
||
},
|
||
{
|
||
scoringEventId: "ev-PRIMARY",
|
||
round: "Round of 16",
|
||
winnerId: "cp-W1",
|
||
loserId: "cp-L1",
|
||
participant1Id: "cp-W1",
|
||
participant2Id: "cp-L1",
|
||
},
|
||
{
|
||
scoringEventId: "ev-PRIMARY",
|
||
round: "Round of 16",
|
||
winnerId: "cp-W2",
|
||
loserId: "cp-L2",
|
||
participant1Id: "cp-W2",
|
||
participant2Id: "cp-L2",
|
||
},
|
||
],
|
||
});
|
||
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: "Wimbledon",
|
||
bracketTemplateId: "tennis_128",
|
||
} as never);
|
||
|
||
// Primary window's derived results (what processQualifyingBracketEvent wrote):
|
||
// four players at placement 9 (2 final losers + 2 floored), two at placement 5.
|
||
vi.mocked(getEventResults).mockResolvedValue(
|
||
[
|
||
["sp-PL1", "cp-L1", 9],
|
||
["sp-PL2", "cp-L2", 9],
|
||
["sp-PF1", "cp-F1", 9],
|
||
["sp-PF2", "cp-F2", 9],
|
||
["sp-PW1", "cp-W1", 5],
|
||
["sp-PW2", "cp-W2", 5],
|
||
].map(([seasonParticipantId, participantId, placement]) => ({
|
||
placement,
|
||
rawScore: null,
|
||
notParticipating: false,
|
||
seasonParticipantId,
|
||
seasonParticipant: { participantId },
|
||
})) as never
|
||
);
|
||
|
||
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;
|
||
}
|
||
);
|
||
|
||
await syncMajorFromPrimaryEvent("ev-PRIMARY", { markComplete: false });
|
||
|
||
// Sanity: only four canonical rows sit at placement 9 (the live count is 4).
|
||
expect(
|
||
state.tournamentResults.filter((r) => r.placement === 9)
|
||
).toHaveLength(4);
|
||
|
||
// The mirror window was scored with the STRUCTURAL span, not the row count.
|
||
const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find(
|
||
(c) => c[0] === "ev-MIRROR"
|
||
);
|
||
expect(mirrorCall).toBeDefined();
|
||
if (!mirrorCall) return;
|
||
const tieMap = mirrorCall[2].canonicalTieCountByPlacement as Map<number, number>;
|
||
expect(tieMap.get(9)).toBe(8); // R16 tier spans 8, not the 4 rows currently there
|
||
expect(tieMap.get(5)).toBe(4); // QF tier spans 4
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|