Migrate participant_golf_skills from per-season to canonical table (one row per real-world player), mirroring the existing participant_surface_elos pattern. Adds admin UI to copy participants between same-sport seasons with EV stubs, transactional writes, and comprehensive tests.
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(),
|
|
}));
|
|
|
|
import { database } from "~/database/context";
|
|
import { getGolfSkillsForSeason, getGolfSkillsMap, batchUpsertGolfSkills } from "../golf-skills";
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
innerJoin: vi.fn().mockReturnThis(),
|
|
leftJoin: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnThis(),
|
|
insert: vi.fn().mockReturnThis(),
|
|
values: vi.fn().mockReturnThis(),
|
|
onConflictDoUpdate: vi.fn().mockReturnThis(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
|
|
|
mockDb.select.mockReturnValue(mockDb);
|
|
mockDb.from.mockReturnValue(mockDb);
|
|
mockDb.where.mockReturnValue(mockDb);
|
|
mockDb.innerJoin.mockReturnValue(mockDb);
|
|
mockDb.leftJoin.mockReturnValue(mockDb);
|
|
mockDb.orderBy.mockReturnValue(mockDb);
|
|
mockDb.insert.mockReturnValue(mockDb);
|
|
mockDb.values.mockReturnValue(mockDb);
|
|
mockDb.onConflictDoUpdate.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("getGolfSkillsMap", () => {
|
|
it("returns an empty Map when no records exist", async () => {
|
|
mockDb.where.mockResolvedValue([]);
|
|
const result = await getGolfSkillsMap("season-1");
|
|
expect(result).toBeInstanceOf(Map);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
|
|
it("returns a Map keyed by seasonParticipantId with sgTotal converted to number", async () => {
|
|
mockDb.where.mockResolvedValue([
|
|
{
|
|
seasonParticipantId: "sp-1",
|
|
sportsSeasonId: "season-1",
|
|
id: "skill-1",
|
|
sgTotal: "2.50",
|
|
datagolfRank: 3,
|
|
mastersOdds: 800,
|
|
usOpenOdds: null,
|
|
openChampionshipOdds: null,
|
|
pgaChampionshipOdds: null,
|
|
updatedAt: new Date("2026-01-01"),
|
|
},
|
|
]);
|
|
const result = await getGolfSkillsMap("season-1");
|
|
expect(result.size).toBe(1);
|
|
const record = result.get("sp-1");
|
|
expect(record).toBeDefined();
|
|
expect(record?.sgTotal).toBe(2.5);
|
|
expect(record?.participantId).toBe("sp-1");
|
|
expect(record?.mastersOdds).toBe(800);
|
|
});
|
|
|
|
it("preserves null sgTotal", async () => {
|
|
mockDb.where.mockResolvedValue([
|
|
{
|
|
seasonParticipantId: "sp-1",
|
|
sportsSeasonId: "season-1",
|
|
id: "skill-1",
|
|
sgTotal: null,
|
|
datagolfRank: null,
|
|
mastersOdds: null,
|
|
usOpenOdds: null,
|
|
openChampionshipOdds: null,
|
|
pgaChampionshipOdds: null,
|
|
updatedAt: new Date("2026-01-01"),
|
|
},
|
|
]);
|
|
const result = await getGolfSkillsMap("season-1");
|
|
expect(result.get("sp-1")?.sgTotal).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getGolfSkillsForSeason", () => {
|
|
it("returns records joined with participant names and converts sgTotal", async () => {
|
|
mockDb.orderBy.mockResolvedValue([
|
|
{
|
|
id: "skill-1",
|
|
participantId: "sp-1",
|
|
sportsSeasonId: "season-1",
|
|
sgTotal: "1.24",
|
|
datagolfRank: 10,
|
|
mastersOdds: 400,
|
|
usOpenOdds: null,
|
|
openChampionshipOdds: null,
|
|
pgaChampionshipOdds: null,
|
|
updatedAt: new Date("2026-01-01"),
|
|
participantName: "Rory McIlroy",
|
|
},
|
|
]);
|
|
const result = await getGolfSkillsForSeason("season-1");
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].participantName).toBe("Rory McIlroy");
|
|
expect(result[0].sgTotal).toBe(1.24);
|
|
expect(result[0].mastersOdds).toBe(400);
|
|
});
|
|
|
|
it("returns empty array when no records exist", async () => {
|
|
mockDb.orderBy.mockResolvedValue([]);
|
|
const result = await getGolfSkillsForSeason("season-1");
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("batchUpsertGolfSkills", () => {
|
|
it("does nothing when given an empty array", async () => {
|
|
await batchUpsertGolfSkills([]);
|
|
expect(mockDb.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("writes canonical rows using resolved canonical participant ids", async () => {
|
|
mockDb.where.mockResolvedValueOnce([
|
|
{ id: "sp-1", canonicalId: "canon-1" },
|
|
{ id: "sp-2", canonicalId: "canon-2" },
|
|
]);
|
|
|
|
await batchUpsertGolfSkills([
|
|
{ participantId: "sp-1", sgTotal: 2.5, mastersOdds: 400 },
|
|
{ participantId: "sp-2", sgTotal: null, usOpenOdds: 800 },
|
|
]);
|
|
|
|
expect(mockDb.insert).toHaveBeenCalledOnce();
|
|
expect(mockDb.values).toHaveBeenCalledOnce();
|
|
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
|
|
|
|
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
|
|
participantId: string;
|
|
sgTotal: string | null;
|
|
mastersOdds: number | null;
|
|
usOpenOdds: number | null;
|
|
}>;
|
|
expect(passedValues).toHaveLength(2);
|
|
expect(passedValues[0].participantId).toBe("canon-1");
|
|
expect(passedValues[0].sgTotal).toBe("2.5");
|
|
expect(passedValues[0].mastersOdds).toBe(400);
|
|
expect(passedValues[1].participantId).toBe("canon-2");
|
|
expect(passedValues[1].sgTotal).toBeNull();
|
|
expect(passedValues[1].usOpenOdds).toBe(800);
|
|
});
|
|
|
|
it("skips season_participants that have no canonical link", async () => {
|
|
mockDb.where.mockResolvedValueOnce([]);
|
|
|
|
await batchUpsertGolfSkills([
|
|
{ participantId: "sp-1", sgTotal: 2.5 },
|
|
]);
|
|
|
|
expect(mockDb.insert).not.toHaveBeenCalled();
|
|
});
|
|
});
|