import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock external DB-touching dependencies before importing the module under test vi.mock("~/services/probability-updater", () => ({ updateProbabilitiesAfterResult: vi.fn().mockResolvedValue(undefined), })); // ── Minimal db mock ──────────────────────────────────────────────────────── // // processMatchResult calls, in order: // 1. upsertParticipantResult (x2) → db.query.participantResults.findFirst, db.insert // 2. recalculateAffectedLeagues → db.query.seasonSports.findMany (returns [] so loop exits) // 3. updateProbabilitiesAfterResult (mocked above) function makeDb(existingResult?: { id: string; isPartialScore: boolean }) { // Track all inserted values in order const insertedRows: object[] = []; const updatedRows: object[] = []; const insertValues = vi.fn().mockImplementation((values: object) => { insertedRows.push(values); return Promise.resolve(); }); const updateWhere = vi.fn().mockResolvedValue(undefined); const updateSet = vi.fn().mockImplementation((values: object) => { updatedRows.push(values); return { where: updateWhere }; }); // participantResults.findFirst: first call gets existingResult (if any), // subsequent calls return undefined (winner/loser don't have existing rows) let findFirstCallCount = 0; const findFirst = vi.fn().mockImplementation(() => { const result = findFirstCallCount === 0 ? existingResult : undefined; findFirstCallCount++; return Promise.resolve(result); }); return { db: { insert: vi.fn().mockReturnValue({ values: insertValues }), update: vi.fn().mockReturnValue({ set: updateSet }), query: { seasonParticipantResults: { findFirst }, seasonSports: { findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately }, sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) }, scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) }, }, } as any, insertedRows, updatedRows, findFirst, insertValues, updateSet, updateWhere, }; } import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator"; import { doesLoserAdvance } from "../playoff-match"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; const BASE = { winnerId: "winner-1", loserId: "loser-1", sportsSeasonId: "ss-1", bracketTemplateId: null as string | null, }; describe("processMatchResult", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("Quarterfinals (scoring)", () => { it("loser gets position 5 (final), winner gets position 3 (provisional)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult( { ...BASE, round: "Quarterfinals", isScoring: true }, db ); expect(insertedRows).toHaveLength(2); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true }); }); it("also works for Elite Eight / Divisional / Conference Semifinals", async () => { for (const round of ["Elite Eight", "Divisional", "Conference Semifinals"]) { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round, isScoring: true }, db); expect(insertedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ finalPosition: 3, isPartialScore: true }); } }); it("recalculates standings (calls db.query.seasonSports.findMany)", async () => { const { db } = makeDb(); await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db); expect((db.query.seasonSports.findMany as ReturnType)).toHaveBeenCalled(); }); }); describe("Semifinals (scoring)", () => { it("loser gets position 3 (final), winner gets position 2 (provisional)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round: "Semifinals", isScoring: true }, db); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 3, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 2, isPartialScore: true }); }); it("also works for Final Four / Conference Finals / Conference Championship", async () => { for (const round of ["Final Four", "Conference Finals", "Conference Championship"]) { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round, isScoring: true }, db); expect(insertedRows[0]).toMatchObject({ finalPosition: 3, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ finalPosition: 2, isPartialScore: true }); } }); }); describe("Finals / Championship (scoring)", () => { it("loser=2nd (final), winner=1st (final), no third row", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round: "Finals", isScoring: true }, db); expect(insertedRows).toHaveLength(2); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 2, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 1, isPartialScore: false }); }); it("handles all Finals aliases the same way", async () => { for (const round of ["Championship", "Super Bowl", "NBA Finals", "Grand Final"]) { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round, isScoring: true }, db); expect(insertedRows).toHaveLength(2); expect(insertedRows[0]).toMatchObject({ finalPosition: 2, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ finalPosition: 1, isPartialScore: false }); } }); }); describe("Non-scoring round (isScoring=false)", () => { it("loser eliminated (pos 0 final), winner gets provisional T5 floor (no template = legacy)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round: "Round of 16", isScoring: false }, db); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); }); describe("NCAA 68 — only Sweet Sixteen winners earn a floor", () => { const NCAA = { bracketTemplateId: "ncaa_68" }; it("Round of 64: loser=0, winner gets NO score (not yet in scoring bracket)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, ...NCAA, round: "Round of 64", isScoring: false }, db); expect(insertedRows).toHaveLength(1); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); }); it("Round of 32: loser=0, winner gets NO score (not yet in scoring bracket)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, ...NCAA, round: "Round of 32", isScoring: false }, db); expect(insertedRows).toHaveLength(1); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); }); it("Sweet Sixteen: loser=0, winner gets provisional T5 floor (entering Elite Eight)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, ...NCAA, round: "Sweet Sixteen", isScoring: false }, db); expect(insertedRows).toHaveLength(2); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); }); it("First Four: loser=0, winner gets NO score (feeds into Round of 64, not scoring)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, ...NCAA, round: "First Four", isScoring: false }, db); expect(insertedRows).toHaveLength(1); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); }); }); it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db); expect(insertedRows).toHaveLength(2); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true }); }); describe("NBA Play-In loserAdvances=true (7v8 game)", () => { it("loser is NOT eliminated when loserAdvances=true (advances to Round 2)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult( { ...BASE, bracketTemplateId: "nba_20", round: "Play-In Round 1", isScoring: false, loserAdvances: true, }, db ); // Neither participant gets a result: loser skipped (still playing), and // winner gets nothing because PIR1 doesn't feed directly into a scoring round. expect(insertedRows).toHaveLength(0); }); it("loser IS eliminated when loserAdvances=false (9v10 game)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult( { ...BASE, bracketTemplateId: "nba_20", round: "Play-In Round 1", isScoring: false, loserAdvances: false, }, db ); expect(insertedRows).toHaveLength(1); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false }); }); }); }); describe("Unrecognized scoring round", () => { it("gives loser 0 pts and still completes without throwing", async () => { const { db, insertedRows } = makeDb(); await processMatchResult({ ...BASE, round: "Mystery Round", isScoring: true }, db); expect(insertedRows[0]).toMatchObject({ finalPosition: 0, isPartialScore: false }); }); }); describe("AFL rounds", () => { it("Elimination Finals: loser exits at position 7 (final)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult( { ...BASE, round: "Elimination Finals", isScoring: true, bracketTemplateId: "afl_10" }, db ); expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 7, isPartialScore: false }); }); it("Qualifying Finals: loser is still alive (provisional floor 5)", async () => { const { db, insertedRows } = makeDb(); await processMatchResult( { ...BASE, round: "Qualifying Finals", isScoring: true, bracketTemplateId: "afl_10" }, db ); // Loser row is provisional (isPartialScore=true) expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: true }); }); }); describe("Idempotency: already-final row is not un-finalized", () => { it("does not overwrite a finalized row with a partial score", async () => { // findFirst returns a finalized row for the first participant (loser) const existing = { id: "result-1", isPartialScore: false }; const { db, updatedRows } = makeDb(existing); await processMatchResult( { ...BASE, round: "Quarterfinals", isScoring: true }, db ); // The guard in upsertParticipantResult should skip the update when // existing.isPartialScore=false and we're trying to write isPartialScore=true. // The loser row would try to write isPartialScore=false (final) — that IS // allowed (both false). The winner row tries isPartialScore=true — since // there's no existing row for the winner (findFirst returns undefined on // second call), it inserts. // // Key assertion: no update/insert with isPartialScore=true was applied to // the existing finalized row. const finalizedOverwrite = updatedRows.find( (r: any) => r.isPartialScore === true ); expect(finalizedOverwrite).toBeUndefined(); }); }); describe("Probability recalculation", () => { it("always calls updateProbabilitiesAfterResult with the sports season id", async () => { const { db } = makeDb(); await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db); expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true); }); it("does not throw even if probability update fails", async () => { (updateProbabilitiesAfterResult as ReturnType).mockRejectedValueOnce( new Error("network error") ); const { db } = makeDb(); // Should not throw — the error is caught and logged await expect( processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db) ).resolves.not.toThrow(); }); }); }); describe("doesLoserAdvance", () => { it("returns true for NBA Play-In Round 1 match 1 (E7v8)", () => { expect(doesLoserAdvance("Play-In Round 1", 1, "nba_20")).toBe(true); }); it("returns true for NBA Play-In Round 1 match 3 (W7v8)", () => { expect(doesLoserAdvance("Play-In Round 1", 3, "nba_20")).toBe(true); }); it("returns false for NBA Play-In Round 1 match 2 (E9v10 — loser eliminated)", () => { expect(doesLoserAdvance("Play-In Round 1", 2, "nba_20")).toBe(false); }); it("returns false for NBA Play-In Round 1 match 4 (W9v10 — loser eliminated)", () => { expect(doesLoserAdvance("Play-In Round 1", 4, "nba_20")).toBe(false); }); it("returns false for NBA Play-In Round 2 (all losers eliminated)", () => { expect(doesLoserAdvance("Play-In Round 2", 1, "nba_20")).toBe(false); expect(doesLoserAdvance("Play-In Round 2", 2, "nba_20")).toBe(false); }); it("returns false for other templates", () => { expect(doesLoserAdvance("Play-In Round 1", 1, "nfl_14")).toBe(false); expect(doesLoserAdvance("Semifinals", 1, "fifa_48")).toBe(false); }); it("returns false for an empty templateId", () => { expect(doesLoserAdvance("Play-In Round 1", 1, "")).toBe(false); }); }); describe("isLoserNotifiable", () => { const LOSER = "loser-1"; const TEAM = "team-1"; it("returns true when the loser's team score changed", () => { expect( isLoserNotifiable(LOSER, TEAM, new Set([TEAM]), new Set()) ).toBe(true); }); it("returns true when the loser is finalized even with no score change (0-pt elimination)", () => { expect( isLoserNotifiable(LOSER, TEAM, new Set(), new Set([LOSER])) ).toBe(true); }); it("returns false when score did not change and loser is not finalized (advances)", () => { expect( isLoserNotifiable(LOSER, TEAM, new Set(), new Set()) ).toBe(false); }); it("returns false when loserId is null", () => { expect( isLoserNotifiable(null, TEAM, new Set([TEAM]), new Set()) ).toBe(false); }); it("returns false when loserId is undefined", () => { expect( isLoserNotifiable(undefined, undefined, new Set(), new Set()) ).toBe(false); }); it("returns false when loser has no team (undrafted) and is not finalized", () => { expect( isLoserNotifiable(LOSER, undefined, new Set(), new Set()) ).toBe(false); }); it("returns true when loser has no team but is finalized (edge case: drafted mid-season?)", () => { // finalizedLoserIds wins regardless of team presence expect( isLoserNotifiable(LOSER, undefined, new Set(), new Set([LOSER])) ).toBe(true); }); }); describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => { function makePlayInDb(matches: object[]) { const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = []; const insertValues = vi.fn().mockImplementation((values: object) => { insertedRows.push(values as typeof insertedRows[0]); return Promise.resolve(); }); return { db: { insert: vi.fn().mockReturnValue({ values: insertValues }), update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }), delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), query: { scoringEvents: { findFirst: vi.fn().mockResolvedValue({ id: "event-1", playoffRound: "Play-In Round 1", bracketTemplateId: "nba_20", sportsSeasonId: "ss-1", name: "NBA Playoffs", sportsSeason: { seasonSports: [{ seasonId: "season-1", season: {} }], }, }), }, playoffMatches: { findMany: vi.fn().mockResolvedValue(matches), }, seasonParticipants: { findMany: vi.fn().mockResolvedValue([]), }, seasonParticipantResults: { findFirst: vi.fn().mockResolvedValue(undefined), }, seasons: { findFirst: vi.fn().mockResolvedValue({ id: "season-1", pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, }), }, seasonSports: { findMany: vi.fn().mockResolvedValue([]) }, sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) }, }, } as any, insertedRows, }; } // All four Play-In Round 1 matches: East 7v8 (M1), East 9v10 (M2), West 7v8 (M3), West 9v10 (M4) const PLAY_IN_MATCHES = [ { id: "m1", round: "Play-In Round 1", matchNumber: 1, isScoring: false, isComplete: true, scoringEventId: "event-1", participant1Id: "winner-78e", participant2Id: "loser-78e", winnerId: "winner-78e", loserId: "loser-78e", }, { id: "m2", round: "Play-In Round 1", matchNumber: 2, isScoring: false, isComplete: true, scoringEventId: "event-1", participant1Id: "winner-910e", participant2Id: "loser-910e", winnerId: "winner-910e", loserId: "loser-910e", }, { id: "m3", round: "Play-In Round 1", matchNumber: 3, isScoring: false, isComplete: true, scoringEventId: "event-1", participant1Id: "winner-78w", participant2Id: "loser-78w", winnerId: "winner-78w", loserId: "loser-78w", }, { id: "m4", round: "Play-In Round 1", matchNumber: 4, isScoring: false, isComplete: true, scoringEventId: "event-1", participant1Id: "winner-910w", participant2Id: "loser-910w", winnerId: "winner-910w", loserId: "loser-910w", }, ]; it("eliminates 9v10 losers (East & West) but NOT 7v8 losers", async () => { const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES); await processPlayoffEvent("event-1", db, { skipRecalculate: true }); const eliminatedIds = insertedRows .filter((r) => r.finalPosition === 0 && !r.isPartialScore) .map((r) => r.participantId); // 7v8 losers advance to Play-In Round 2 — must not be eliminated yet expect(eliminatedIds).not.toContain("loser-78e"); expect(eliminatedIds).not.toContain("loser-78w"); // 9v10 losers are out immediately expect(eliminatedIds).toContain("loser-910e"); expect(eliminatedIds).toContain("loser-910w"); }); it("eliminates ALL losers when bracketTemplateId is missing (falls back to safe default)", async () => { // Guards against a future refactor that drops bracketTemplateId from the DB query. // doesLoserAdvance receives "" → returns false for all matches → all losers eliminated. const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES); (db.query.scoringEvents.findFirst as ReturnType).mockResolvedValueOnce({ id: "event-1", playoffRound: "Play-In Round 1", bracketTemplateId: null, // simulates missing templateId sportsSeasonId: "ss-1", name: "NBA Playoffs", sportsSeason: { seasonSports: [{ seasonId: "season-1", season: {} }], }, }); await processPlayoffEvent("event-1", db, { skipRecalculate: true }); const eliminatedIds = insertedRows .filter((r) => r.finalPosition === 0 && !r.isPartialScore) .map((r) => r.participantId); expect(eliminatedIds).toContain("loser-78e"); expect(eliminatedIds).toContain("loser-78w"); }); });