2026-06-25 17:43:31 +00:00
|
|
|
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";
|
2026-06-30 17:05:52 +00:00
|
|
|
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
2026-06-25 17:43:31 +00:00
|
|
|
|
|
|
|
|
type SetPayload = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
const conflictSetCalls: SetPayload[] = [];
|
|
|
|
|
|
2026-06-26 05:16:54 +00:00
|
|
|
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
|
|
|
|
conflictSetCalls.push(arg.set);
|
|
|
|
|
return Promise.resolve(undefined);
|
|
|
|
|
});
|
2026-06-25 17:43:31 +00:00
|
|
|
|
2026-06-30 17:05:52 +00:00
|
|
|
const tx = {
|
2026-06-26 05:16:54 +00:00
|
|
|
insert: vi.fn(() => ({
|
|
|
|
|
values: vi.fn(() => ({ onConflictDoUpdate })),
|
|
|
|
|
})),
|
2026-06-25 17:43:31 +00:00
|
|
|
};
|
|
|
|
|
|
2026-06-30 17:05:52 +00:00
|
|
|
const mockDb = {
|
|
|
|
|
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-25 17:43:31 +00:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
conflictSetCalls.length = 0;
|
|
|
|
|
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-30 17:05:52 +00:00
|
|
|
// 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.
|
2026-06-25 17:43:31 +00:00
|
|
|
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
|
|
|
|
]);
|
|
|
|
|
|
2026-06-30 17:05:52 +00:00
|
|
|
// 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");
|
2026-06-25 17:43:31 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("is a no-op when given no inputs", async () => {
|
2026-06-30 17:05:52 +00:00
|
|
|
await batchUpsertParticipantSimulatorInputs([]);
|
|
|
|
|
expect(mockDb.transaction).not.toHaveBeenCalled();
|
2026-06-25 17:43:31 +00:00
|
|
|
});
|
|
|
|
|
});
|