From b83d1c864303f40d20a67a0bdf372c1df70c2008 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 27 Feb 2026 21:44:43 -0800 Subject: [PATCH] fix: remove erroneous ?? fallbacks in autodraft socket emissions and add autoPickForTeam tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `?? true` default on queueOnly in queue-empty auto-disable socket emit (line 488) — was dead code since the column is NOT NULL, but semantically wrong and would have caused client-side UI desync if the type ever relaxed - Remove `?? false` default on the next_pick auto-disable path for consistency - Add app/models/__tests__/auto-pick.test.ts with 6 tests covering the queueOnly constraint: empty queue, all items drafted, partial queue skip, and EV fallback Co-Authored-By: Claude Sonnet 4.6 --- app/models/__tests__/auto-pick.test.ts | 304 +++++++++++++++++++++++++ app/models/draft-utils.ts | 4 +- 2 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 app/models/__tests__/auto-pick.test.ts diff --git a/app/models/__tests__/auto-pick.test.ts b/app/models/__tests__/auto-pick.test.ts new file mode 100644 index 0000000..f4229f2 --- /dev/null +++ b/app/models/__tests__/auto-pick.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { autoPickForTeam } from "../draft-utils"; + +// Mock all external model dependencies so we can control their return values +vi.mock("~/models/draft-pick", () => ({ + getDraftPicksWithSports: vi.fn(), + getTeamDraftPicksWithSports: vi.fn(), + isParticipantDrafted: vi.fn(), +})); + +vi.mock("~/models/participant", () => ({ + getParticipantsForSeasonWithSports: vi.fn(), +})); + +vi.mock("~/models/season-sport", () => ({ + getSeasonSportsSimple: vi.fn(), +})); + +vi.mock("~/models/draft-queue", () => ({ + getTeamQueue: vi.fn(), +})); + +vi.mock("~/lib/draft-eligibility", () => ({ + calculateDraftEligibility: vi.fn(), +})); + +// Prevent actual socket/db usage +vi.mock("~/server/socket"); +vi.mock("~/database/context"); + +import { + getDraftPicksWithSports, + getTeamDraftPicksWithSports, + isParticipantDrafted, +} from "~/models/draft-pick"; +import { getParticipantsForSeasonWithSports } from "~/models/participant"; +import { getSeasonSportsSimple } from "~/models/season-sport"; +import { getTeamQueue } from "~/models/draft-queue"; +import { calculateDraftEligibility } from "~/lib/draft-eligibility"; + +const SEASON_ID = "season-1"; +const TEAM_ID = "team-1"; +const DRAFT_ROUNDS = 10; +const ALL_TEAM_IDS = [TEAM_ID, "team-2"]; + +// A minimal mock db for cases that do NOT reach getTopAvailableParticipant. +// `where` resolves to [] so `await db.select()...where()` yields an array. +function makeMockDb(overrides: Record = {}) { + const mockDb: Record = { + query: { + participants: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + orderBy: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockReturnThis(), + ...overrides, + }; + return mockDb; +} + +// Build a mock db whose select chain returns a real EV participant. +// getTopAvailableParticipant makes three sequential awaitable calls: +// 1. select().from().where() → drafted picks → [] +// 2. select().from().innerJoin()×2.where() → season sports → [{sportsSeasonId, sportId}] +// 3. select().from().where().orderBy() → top participant → [{id, ...}] +function makeMockDbWithEvParticipant(participantId: string) { + return { + query: { participants: { findMany: vi.fn().mockResolvedValue([]) } }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn() + .mockResolvedValueOnce([]) // call 1: drafted picks + .mockResolvedValueOnce([{ sportsSeasonId: "ss-1", sportId: "sport-1" }]) // call 2: season sports + .mockReturnThis(), // call 3: chain to orderBy + orderBy: vi.fn().mockResolvedValue([ + { id: participantId, name: "EV Player", expectedValue: "100.00" }, + ]), + delete: vi.fn().mockReturnThis(), + }; +} + +// Minimal eligibility stub — makes every sport eligible. +function stubEligibility() { + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set(["sport-1"]), + } as ReturnType); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + stubEligibility(); +}); + +describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => { + describe("empty queue", () => { + it("returns null when queueOnly=true (no EV fallback)", async () => { + vi.mocked(getTeamQueue).mockResolvedValue([]); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + makeMockDb() as any, + true // queueOnly + ); + + expect(result).toBeNull(); + }); + + it("returns the top EV participant when queueOnly=false (falls back to EV)", async () => { + vi.mocked(getTeamQueue).mockResolvedValue([]); + + const mockDb = makeMockDbWithEvParticipant("p-ev"); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + false // queueOnly off + ); + + // Must return the EV participant — proves the EV path ran, not the queueOnly guard. + expect(result).toBe("p-ev"); + }); + }); + + describe("all queue items already drafted", () => { + it("returns null when queueOnly=true and every queued participant is taken", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + // Both participants are already drafted + vi.mocked(isParticipantDrafted).mockResolvedValue(true); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true // queueOnly + ); + + expect(result).toBeNull(); + }); + + it("removes stale queue entries when all items are drafted", async () => { + const queue = [{ id: "q-1", participantId: "p-1", queuePosition: 1 }]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDelete = vi.fn().mockReturnThis(); + const mockWhere = vi.fn().mockResolvedValue(undefined); + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + delete: mockDelete, + where: mockWhere, + }); + + vi.mocked(isParticipantDrafted).mockResolvedValue(true); + + await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true + ); + + // Stale items should be cleaned from the queue + expect(mockDelete).toHaveBeenCalled(); + }); + }); + + describe("partial queue — first item drafted, second available", () => { + it("skips drafted item and returns the next valid participant", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + // p-1 is already drafted, p-2 is available + vi.mocked(isParticipantDrafted).mockImplementation( + async (_seasonId, participantId) => participantId === "p-1" + ); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true // queueOnly — still uses queue, just no EV fallback + ); + + expect(result).toBe("p-2"); + }); + + it("returns the first available participant when none are drafted (happy path)", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + vi.mocked(isParticipantDrafted).mockResolvedValue(false); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true + ); + + expect(result).toBe("p-1"); + }); + }); +}); diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index f91929a..520e1ad 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -485,7 +485,7 @@ export async function executeAutoPick(params: { teamId, isEnabled: false, mode: autodraftSettings.mode, - queueOnly: autodraftSettings.queueOnly ?? true, + queueOnly: autodraftSettings.queueOnly, }); } catch (error) { console.error("[AutoPick] Socket.IO autodraft-updated error (queue-empty shutoff):", error); @@ -635,7 +635,7 @@ export async function executeAutoPick(params: { teamId, isEnabled: false, mode: autodraftSettings.mode, - queueOnly: autodraftSettings.queueOnly ?? false, + queueOnly: autodraftSettings.queueOnly, }); } catch (error) { console.error("[AutoPick] Socket.IO autodraft-updated error:", error);