import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock the database context before importing any model vi.mock("~/database/context", () => ({ database: vi.fn(), })); import { database } from "~/database/context"; import { batchUpsertParticipantSimulatorInputs } from "../simulator"; type SetPayload = Record; const conflictSetCalls: SetPayload[] = []; const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => { conflictSetCalls.push(arg.set); return Promise.resolve(undefined); }); const tx = { insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate })), })), }; const mockDb = { transaction: vi.fn((cb: (t: typeof tx) => Promise) => cb(tx)), }; beforeEach(() => { vi.clearAllMocks(); conflictSetCalls.length = 0; (database as ReturnType).mockReturnValue(mockDb); }); // Recursively flatten a Drizzle `sql` template into its static text so we can // assert how a conflict-update column is built (e.g. wrapped in COALESCE). function sqlToText(value: unknown): string { if (value === null || value === undefined) return ""; const chunks = (value as { queryChunks?: unknown[] }).queryChunks; if (Array.isArray(chunks)) return chunks.map(sqlToText).join(""); const stringValue = (value as { value?: unknown }).value; if (Array.isArray(stringValue)) return stringValue.join(""); if (typeof stringValue === "string") return stringValue; return ""; } describe("batchUpsertParticipantSimulatorInputs", () => { it("updates only the columns it is given, preserving the rest (non-destructive)", async () => { await batchUpsertParticipantSimulatorInputs([ // Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied. { participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 }, ]); // Runs in a transaction, writing the simulator-inputs row first then the // legacy EV bridge row. expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1); const simulatorSet = conflictSetCalls[0]; // Every input column participates in the conflict update so partial pastes // can target any field... for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) { expect(simulatorSet).toHaveProperty(column); } // ...but each is COALESCE-wrapped, so a null incoming value keeps the stored // one instead of clobbering it. A bare `excluded.*` here would be the bug. const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase(); expect(sourceEloSql).toContain("coalesce"); const ratingSql = sqlToText(simulatorSet.rating).toLowerCase(); expect(ratingSql).toContain("coalesce"); // Metadata must drop the per-column method flag whenever that column gets a // fresh direct value, so a stale "generated" flag can't hide a newly entered // Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug. const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase(); expect(metadataSql).toContain("sourceelomethod"); expect(metadataSql).toContain("ratingmethod"); expect(metadataSql).toContain("excluded.source_elo"); expect(metadataSql).toContain("excluded.rating"); }); it("is a no-op when given no inputs", async () => { await batchUpsertParticipantSimulatorInputs([]); expect(mockDb.transaction).not.toHaveBeenCalled(); }); });