feat(services): syncTournamentResults fan-out service
This commit is contained in:
parent
cda578d0ac
commit
faa0c0aa72
2 changed files with 716 additions and 0 deletions
578
app/services/__tests__/sync-tournament-results.test.ts
Normal file
578
app/services/__tests__/sync-tournament-results.test.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
processQualifyingEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { syncTournamentResults } from "../sync-tournament-results";
|
||||
import { database } from "~/database/context";
|
||||
import { processQualifyingEvent } from "~/models/scoring-calculator";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeTournamentResult {
|
||||
tournamentId: string;
|
||||
participantId: string;
|
||||
placement: number | null;
|
||||
rawScore: string | null;
|
||||
}
|
||||
|
||||
interface FakeScoringEvent {
|
||||
id: string;
|
||||
sportsSeasonId: string;
|
||||
tournamentId: 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.
|
||||
*/
|
||||
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.
|
||||
// drizzle PgTables expose `[Symbol.for("drizzle:Name")]` but simpler: we
|
||||
// just check a known property. Schema tables have `._.name`.
|
||||
// Fallback: we compare string representation.
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 import("drizzle-orm")>("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);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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);
|
||||
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
138
app/services/sync-tournament-results.ts
Normal file
138
app/services/sync-tournament-results.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { processQualifyingEvent } from "~/models/scoring-calculator";
|
||||
|
||||
export interface SyncReport {
|
||||
tournamentId: string;
|
||||
windowsSynced: number;
|
||||
windowsFailed: number;
|
||||
failures: Array<{
|
||||
scoringEventId: string;
|
||||
sportsSeasonId: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out canonical tournament_results to every scoring_event window that
|
||||
* points at this tournament. For each linked window:
|
||||
* 1. Upsert event_results rows mirroring canonical placement / rawScore for
|
||||
* every roster participant that appears in the canonical results.
|
||||
* 2. Write 0-QP filler rows for roster participants who have no result.
|
||||
* 3. Delegate to processQualifyingEvent to compute QP.
|
||||
*
|
||||
* Each window runs in its own transaction so partial failures isolate.
|
||||
* Errors in one window never interrupt sync of the remaining windows.
|
||||
*
|
||||
* This service NEVER writes qualifying_points_awarded directly (except the
|
||||
* "0" filler for participants with no placement) — QP calculation for real
|
||||
* results is owned by processQualifyingEvent.
|
||||
*/
|
||||
export async function syncTournamentResults(
|
||||
tournamentId: string
|
||||
): Promise<SyncReport> {
|
||||
const db = database();
|
||||
|
||||
const report: SyncReport = {
|
||||
tournamentId,
|
||||
windowsSynced: 0,
|
||||
windowsFailed: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
// 1. Load canonical results for this tournament.
|
||||
const canonicalResults = await db
|
||||
.select()
|
||||
.from(schema.tournamentResults)
|
||||
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
|
||||
|
||||
// 2. Load every scoring_event that points at this tournament.
|
||||
const linkedEvents = await db
|
||||
.select()
|
||||
.from(schema.scoringEvents)
|
||||
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
|
||||
|
||||
// 3. Fan out — each event gets its own transaction.
|
||||
for (const ev of linkedEvents) {
|
||||
try {
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// 3a. Window roster = season_participants for this event's sports_season.
|
||||
const rosters = await tx
|
||||
.select()
|
||||
.from(schema.seasonParticipants)
|
||||
.where(eq(schema.seasonParticipants.sportsSeasonId, ev.sportsSeasonId));
|
||||
|
||||
// 3b. For each canonical result whose participant is on the roster,
|
||||
// upsert the matching event_results row. Only placement / rawScore
|
||||
// are copied; qualifying_points_awarded is left for
|
||||
// processQualifyingEvent to recompute.
|
||||
for (const cr of canonicalResults) {
|
||||
const sp = rosters.find((r) => r.participantId === cr.participantId);
|
||||
if (!sp) continue;
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.eventResults)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.eventResults.scoringEventId, ev.id),
|
||||
eq(schema.eventResults.seasonParticipantId, sp.id)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
placement: cr.placement,
|
||||
rawScore: cr.rawScore,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(schema.eventResults).values({
|
||||
scoringEventId: ev.id,
|
||||
seasonParticipantId: sp.id,
|
||||
placement: cr.placement,
|
||||
rawScore: cr.rawScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. 0-QP filler pass: every roster member without an event_results
|
||||
// row for this event gets one with placement=null, QP=0. This matches
|
||||
// the behavior from the per-window batch-qualifying-results design.
|
||||
const afterRows = await tx
|
||||
.select()
|
||||
.from(schema.eventResults)
|
||||
.where(eq(schema.eventResults.scoringEventId, ev.id));
|
||||
const covered = new Set(afterRows.map((r) => r.seasonParticipantId));
|
||||
for (const sp of rosters) {
|
||||
if (covered.has(sp.id)) continue;
|
||||
await tx.insert(schema.eventResults).values({
|
||||
scoringEventId: ev.id,
|
||||
seasonParticipantId: sp.id,
|
||||
placement: null,
|
||||
qualifyingPointsAwarded: "0",
|
||||
});
|
||||
}
|
||||
|
||||
// 3d. Delegate to scoring engine inside the same transaction.
|
||||
await processQualifyingEvent(ev.id, tx);
|
||||
});
|
||||
report.windowsSynced += 1;
|
||||
} catch (e: unknown) {
|
||||
report.windowsFailed += 1;
|
||||
const msg =
|
||||
e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
|
||||
report.failures.push({
|
||||
scoringEventId: ev.id,
|
||||
sportsSeasonId: ev.sportsSeasonId,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue