910 lines
34 KiB
TypeScript
910 lines
34 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(),
|
|
// Pure helper — keep the real implementation so the tie-count map handed to
|
|
// processQualifyingEvent reflects the mock canonical results.
|
|
buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => {
|
|
const map = new Map<number, number>();
|
|
for (const r of results) {
|
|
if (r.placement === null) continue;
|
|
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
|
}
|
|
return map;
|
|
},
|
|
}));
|
|
|
|
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,
|
|
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("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");
|
|
});
|
|
});
|