diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts new file mode 100644 index 0000000..98eae90 --- /dev/null +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -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("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 = { + 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 { + 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"); + }); +}); diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts new file mode 100644 index 0000000..786f913 --- /dev/null +++ b/app/services/sync-tournament-results.ts @@ -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 { + 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; +}