diff --git a/app/models/__tests__/canonical-surface-elo.test.ts b/app/models/__tests__/canonical-surface-elo.test.ts new file mode 100644 index 0000000..db21c21 --- /dev/null +++ b/app/models/__tests__/canonical-surface-elo.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { + upsertCanonicalSurfaceElo, + getCanonicalSurfaceElo, +} from "../canonical-surface-elo"; +import { database } from "~/database/context"; + +const PARTICIPANT_ID = "participant-1"; + +const SAMPLE_ELO = { + id: "elo-1", + participantId: PARTICIPANT_ID, + worldRanking: 1, + eloHard: 2400, + eloClay: 2350, + eloGrass: 2300, + updatedAt: new Date("2026-01-28T00:00:00Z"), +}; + +function makeUpsertDb(returnValue: object) { + return { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + onConflictDoUpdate: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([returnValue]), + }), + }), + }), + }; +} + +function makeSelectDb(rows: object[]) { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + }), + }), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("upsertCanonicalSurfaceElo", () => { + it("inserts or updates surface elo data", async () => { + vi.mocked(database).mockReturnValue(makeUpsertDb(SAMPLE_ELO) as never); + + const result = await upsertCanonicalSurfaceElo({ + participantId: PARTICIPANT_ID, + worldRanking: 1, + eloHard: 2400, + eloClay: 2350, + eloGrass: 2300, + }); + + expect(result).toEqual(SAMPLE_ELO); + }); + + it("uses onConflictDoUpdate on participantId", async () => { + const mockDb = makeUpsertDb(SAMPLE_ELO); + vi.mocked(database).mockReturnValue(mockDb as never); + + await upsertCanonicalSurfaceElo({ + participantId: PARTICIPANT_ID, + worldRanking: 1, + }); + + const onConflictCall = (mockDb.insert as ReturnType) + .mock.results[0].value.values.mock.results[0].value.onConflictDoUpdate; + + expect(onConflictCall).toHaveBeenCalled(); + }); +}); + +describe("getCanonicalSurfaceElo", () => { + it("returns elo data when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_ELO]) as never); + + const result = await getCanonicalSurfaceElo(PARTICIPANT_ID); + + expect(result).toEqual(SAMPLE_ELO); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await getCanonicalSurfaceElo("nonexistent"); + + expect(result).toBeNull(); + }); +}); diff --git a/app/models/__tests__/participant.test.ts b/app/models/__tests__/participant.test.ts new file mode 100644 index 0000000..3d0ee21 --- /dev/null +++ b/app/models/__tests__/participant.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { + createCanonicalParticipant, + getCanonicalParticipantById, + findCanonicalParticipantsBySport, + findCanonicalParticipantBySportName, + upsertCanonicalParticipantBySportName, +} from "../participant"; +import { database } from "~/database/context"; + +const SPORT_ID = "sport-1"; +const PARTICIPANT_ID = "participant-1"; + +const SAMPLE_PARTICIPANT = { + id: PARTICIPANT_ID, + sportId: SPORT_ID, + name: "Novak Djokovic", + externalKey: "djokovic-n", + metadata: { atp_id: "12345" }, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), +}; + +function makeInsertDb(returnValue: object) { + return { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([returnValue]), + }), + }), + }; +} + +function makeSelectDb(rows: object[]) { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + orderBy: vi.fn().mockResolvedValue(rows), + }), + }), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createCanonicalParticipant", () => { + it("inserts a participant and returns it", async () => { + vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_PARTICIPANT) as never); + + const result = await createCanonicalParticipant({ + sportId: SPORT_ID, + name: "Novak Djokovic", + }); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); +}); + +describe("getCanonicalParticipantById", () => { + it("returns the participant when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never); + + const result = await getCanonicalParticipantById(PARTICIPANT_ID); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await getCanonicalParticipantById("nonexistent"); + + expect(result).toBeNull(); + }); +}); + +describe("findCanonicalParticipantsBySport", () => { + it("returns participants ordered by name", async () => { + const participants = [SAMPLE_PARTICIPANT]; + vi.mocked(database).mockReturnValue(makeSelectDb(participants) as never); + + const result = await findCanonicalParticipantsBySport(SPORT_ID); + + expect(result).toEqual(participants); + }); +}); + +describe("findCanonicalParticipantBySportName", () => { + it("returns the participant when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never); + + const result = await findCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic"); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await findCanonicalParticipantBySportName(SPORT_ID, "Roger Federer"); + + expect(result).toBeNull(); + }); +}); + +describe("upsertCanonicalParticipantBySportName", () => { + it("returns existing participant if found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_PARTICIPANT]) as never); + + const result = await upsertCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic"); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); + + it("creates new participant if not found", async () => { + let callCount = 0; + vi.mocked(database).mockImplementation(() => { + callCount++; + return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_PARTICIPANT)) as never; + }); + + const result = await upsertCanonicalParticipantBySportName(SPORT_ID, "Novak Djokovic"); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); + + it("passes extra fields when creating", async () => { + let callCount = 0; + vi.mocked(database).mockImplementation(() => { + callCount++; + return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_PARTICIPANT)) as never; + }); + + const result = await upsertCanonicalParticipantBySportName( + SPORT_ID, + "Novak Djokovic", + { externalKey: "djokovic-n" } + ); + + expect(result).toEqual(SAMPLE_PARTICIPANT); + }); +}); diff --git a/app/models/__tests__/tournament-result.test.ts b/app/models/__tests__/tournament-result.test.ts new file mode 100644 index 0000000..0a6f440 --- /dev/null +++ b/app/models/__tests__/tournament-result.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { + upsertTournamentResult, + getTournamentResults, + getTournamentResultByParticipant, +} from "../tournament-result"; +import { database } from "~/database/context"; + +const TOURNAMENT_ID = "tournament-1"; +const PARTICIPANT_ID = "participant-1"; + +const SAMPLE_RESULT = { + id: "result-1", + tournamentId: TOURNAMENT_ID, + participantId: PARTICIPANT_ID, + placement: 1, + rawScore: "7500.00", + createdAt: new Date("2026-01-28T00:00:00Z"), + updatedAt: new Date("2026-01-28T00:00:00Z"), +}; + +function makeUpsertDb(returnValue: object) { + return { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + onConflictDoUpdate: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([returnValue]), + }), + }), + }), + }; +} + +function makeSelectDb(rows: object[]) { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + orderBy: vi.fn().mockResolvedValue(rows), + }), + }), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("upsertTournamentResult", () => { + it("inserts or updates a result", async () => { + vi.mocked(database).mockReturnValue(makeUpsertDb(SAMPLE_RESULT) as never); + + const result = await upsertTournamentResult({ + tournamentId: TOURNAMENT_ID, + participantId: PARTICIPANT_ID, + placement: 1, + rawScore: "7500.00", + }); + + expect(result).toEqual(SAMPLE_RESULT); + }); + + it("uses onConflictDoUpdate on tournamentId and participantId", async () => { + const mockDb = makeUpsertDb(SAMPLE_RESULT); + vi.mocked(database).mockReturnValue(mockDb as never); + + await upsertTournamentResult({ + tournamentId: TOURNAMENT_ID, + participantId: PARTICIPANT_ID, + placement: 1, + }); + + const onConflictCall = (mockDb.insert as ReturnType) + .mock.results[0].value.values.mock.results[0].value.onConflictDoUpdate; + + expect(onConflictCall).toHaveBeenCalled(); + }); +}); + +describe("getTournamentResults", () => { + it("returns results ordered by placement", async () => { + const results = [SAMPLE_RESULT]; + vi.mocked(database).mockReturnValue(makeSelectDb(results) as never); + + const result = await getTournamentResults(TOURNAMENT_ID); + + expect(result).toEqual(results); + }); +}); + +describe("getTournamentResultByParticipant", () => { + it("returns the result when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_RESULT]) as never); + + const result = await getTournamentResultByParticipant(TOURNAMENT_ID, PARTICIPANT_ID); + + expect(result).toEqual(SAMPLE_RESULT); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await getTournamentResultByParticipant(TOURNAMENT_ID, "nonexistent"); + + expect(result).toBeNull(); + }); +}); diff --git a/app/models/__tests__/tournament.test.ts b/app/models/__tests__/tournament.test.ts new file mode 100644 index 0000000..e7f0406 --- /dev/null +++ b/app/models/__tests__/tournament.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { + createTournament, + getTournamentById, + findTournamentsBySport, + findTournamentBySportNameYear, + upsertTournament, + updateTournamentStatus, +} from "../tournament"; +import { database } from "~/database/context"; + +const SPORT_ID = "sport-1"; +const TOURNAMENT_ID = "tournament-1"; + +const SAMPLE_TOURNAMENT = { + id: TOURNAMENT_ID, + sportId: SPORT_ID, + name: "Australian Open", + year: 2026, + startsAt: new Date("2026-01-15T00:00:00Z"), + endsAt: new Date("2026-01-28T00:00:00Z"), + surface: "hard", + location: "Melbourne", + status: "scheduled" as const, + externalKey: "ao-2026", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), +}; + +function makeInsertDb(returnValue: object) { + return { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([returnValue]), + }), + }), + }; +} + +function makeSelectDb(rows: object[]) { + return { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + orderBy: vi.fn().mockResolvedValue(rows), + }), + }), + }), + }; +} + +function makeUpdateDb(returnValue: object) { + return { + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([returnValue]), + }), + }), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createTournament", () => { + it("inserts a tournament and returns it", async () => { + vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_TOURNAMENT) as never); + + const result = await createTournament({ + sportId: SPORT_ID, + name: "Australian Open", + year: 2026, + }); + + expect(result).toEqual(SAMPLE_TOURNAMENT); + }); +}); + +describe("getTournamentById", () => { + it("returns the tournament when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never); + + const result = await getTournamentById(TOURNAMENT_ID); + + expect(result).toEqual(SAMPLE_TOURNAMENT); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await getTournamentById("nonexistent"); + + expect(result).toBeNull(); + }); +}); + +describe("findTournamentsBySport", () => { + it("returns tournaments ordered by year and date", async () => { + const tournaments = [SAMPLE_TOURNAMENT]; + vi.mocked(database).mockReturnValue(makeSelectDb(tournaments) as never); + + const result = await findTournamentsBySport(SPORT_ID); + + expect(result).toEqual(tournaments); + }); +}); + +describe("findTournamentBySportNameYear", () => { + it("returns the tournament when found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never); + + const result = await findTournamentBySportNameYear(SPORT_ID, "Australian Open", 2026); + + expect(result).toEqual(SAMPLE_TOURNAMENT); + }); + + it("returns null when not found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([]) as never); + + const result = await findTournamentBySportNameYear(SPORT_ID, "Wimbledon", 2026); + + expect(result).toBeNull(); + }); +}); + +describe("upsertTournament", () => { + it("returns existing tournament if found", async () => { + vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_TOURNAMENT]) as never); + + const result = await upsertTournament({ + sportId: SPORT_ID, + name: "Australian Open", + year: 2026, + }); + + expect(result).toEqual(SAMPLE_TOURNAMENT); + }); + + it("creates new tournament if not found", async () => { + let callCount = 0; + vi.mocked(database).mockImplementation(() => { + callCount++; + return (callCount === 1 ? makeSelectDb([]) : makeInsertDb(SAMPLE_TOURNAMENT)) as never; + }); + + const result = await upsertTournament({ + sportId: SPORT_ID, + name: "Australian Open", + year: 2026, + }); + + expect(result).toEqual(SAMPLE_TOURNAMENT); + }); +}); + +describe("updateTournamentStatus", () => { + it("updates status and returns the tournament", async () => { + const updated = { ...SAMPLE_TOURNAMENT, status: "in_progress" as const }; + vi.mocked(database).mockReturnValue(makeUpdateDb(updated) as never); + + const result = await updateTournamentStatus(TOURNAMENT_ID, "in_progress"); + + expect(result.status).toBe("in_progress"); + }); +}); diff --git a/app/models/canonical-surface-elo.ts b/app/models/canonical-surface-elo.ts new file mode 100644 index 0000000..4267e3e --- /dev/null +++ b/app/models/canonical-surface-elo.ts @@ -0,0 +1,43 @@ +import { eq } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type CanonicalSurfaceElo = typeof schema.participantSurfaceElos.$inferSelect; +export type NewCanonicalSurfaceElo = typeof schema.participantSurfaceElos.$inferInsert; + +export async function upsertCanonicalSurfaceElo( + data: NewCanonicalSurfaceElo +): Promise { + if (!data.participantId) { + throw new Error("participantId is required"); + } + + const db = database(); + const [result] = await db + .insert(schema.participantSurfaceElos) + .values(data) + .onConflictDoUpdate({ + target: [schema.participantSurfaceElos.participantId], + set: { + worldRanking: data.worldRanking, + eloHard: data.eloHard, + eloClay: data.eloClay, + eloGrass: data.eloGrass, + updatedAt: new Date(), + }, + }) + .returning(); + return result; +} + +export async function getCanonicalSurfaceElo( + participantId: string +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.participantSurfaceElos) + .where(eq(schema.participantSurfaceElos.participantId, participantId)) + .limit(1); + return results[0] ?? null; +} diff --git a/app/models/index.ts b/app/models/index.ts index 93d27ec..045f646 100644 --- a/app/models/index.ts +++ b/app/models/index.ts @@ -18,3 +18,7 @@ export * from "./draft-timer"; export * from "./draft-utils"; export * from "./watchlist"; export * from "./audit-log"; +export * from "./tournament"; +export * from "./participant"; +export * from "./tournament-result"; +export * from "./canonical-surface-elo"; diff --git a/app/models/participant.ts b/app/models/participant.ts new file mode 100644 index 0000000..e9ba816 --- /dev/null +++ b/app/models/participant.ts @@ -0,0 +1,76 @@ +import { eq, and, asc } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type CanonicalParticipant = typeof schema.participants.$inferSelect; +export type NewCanonicalParticipant = typeof schema.participants.$inferInsert; + +export async function createCanonicalParticipant( + data: NewCanonicalParticipant +): Promise { + const db = database(); + const [participant] = await db + .insert(schema.participants) + .values(data) + .returning(); + return participant; +} + +export async function getCanonicalParticipantById( + id: string +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.participants) + .where(eq(schema.participants.id, id)) + .limit(1); + return results[0] ?? null; +} + +export async function findCanonicalParticipantsBySport( + sportId: string +): Promise { + const db = database(); + return await db + .select() + .from(schema.participants) + .where(eq(schema.participants.sportId, sportId)) + .orderBy(asc(schema.participants.name)); +} + +export async function findCanonicalParticipantBySportName( + sportId: string, + name: string +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.participants) + .where( + and( + eq(schema.participants.sportId, sportId), + eq(schema.participants.name, name) + ) + ) + .limit(1); + return results[0] ?? null; +} + +export async function upsertCanonicalParticipantBySportName( + sportId: string, + name: string, + extra?: Partial +): Promise { + const existing = await findCanonicalParticipantBySportName(sportId, name); + + if (existing) { + return existing; + } + + return await createCanonicalParticipant({ + sportId, + name, + ...extra, + }); +} diff --git a/app/models/tournament-result.ts b/app/models/tournament-result.ts new file mode 100644 index 0000000..103a1ce --- /dev/null +++ b/app/models/tournament-result.ts @@ -0,0 +1,61 @@ +import { eq, and, asc } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type TournamentResult = typeof schema.tournamentResults.$inferSelect; +export type NewTournamentResult = typeof schema.tournamentResults.$inferInsert; + +export async function upsertTournamentResult( + data: NewTournamentResult +): Promise { + if (!data.tournamentId || !data.participantId) { + throw new Error("tournamentId and participantId are required"); + } + + const db = database(); + const [result] = await db + .insert(schema.tournamentResults) + .values(data) + .onConflictDoUpdate({ + target: [ + schema.tournamentResults.tournamentId, + schema.tournamentResults.participantId, + ], + set: { + placement: data.placement, + rawScore: data.rawScore, + updatedAt: new Date(), + }, + }) + .returning(); + return result; +} + +export async function getTournamentResults( + tournamentId: string +): Promise { + const db = database(); + return await db + .select() + .from(schema.tournamentResults) + .where(eq(schema.tournamentResults.tournamentId, tournamentId)) + .orderBy(asc(schema.tournamentResults.placement)); +} + +export async function getTournamentResultByParticipant( + tournamentId: string, + participantId: string +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.tournamentResults) + .where( + and( + eq(schema.tournamentResults.tournamentId, tournamentId), + eq(schema.tournamentResults.participantId, participantId) + ) + ) + .limit(1); + return results[0] ?? null; +} diff --git a/app/models/tournament.ts b/app/models/tournament.ts new file mode 100644 index 0000000..e26659c --- /dev/null +++ b/app/models/tournament.ts @@ -0,0 +1,94 @@ +import { eq, and, asc } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type Tournament = typeof schema.tournaments.$inferSelect; +export type NewTournament = typeof schema.tournaments.$inferInsert; +export type TournamentStatus = Tournament["status"]; + +export async function createTournament( + data: NewTournament +): Promise { + const db = database(); + const [tournament] = await db + .insert(schema.tournaments) + .values(data) + .returning(); + return tournament; +} + +export async function getTournamentById( + id: string +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.tournaments) + .where(eq(schema.tournaments.id, id)) + .limit(1); + return results[0] ?? null; +} + +export async function findTournamentsBySport( + sportId: string +): Promise { + const db = database(); + return await db + .select() + .from(schema.tournaments) + .where(eq(schema.tournaments.sportId, sportId)) + .orderBy(asc(schema.tournaments.year), asc(schema.tournaments.startsAt)); +} + +export async function findTournamentBySportNameYear( + sportId: string, + name: string, + year: number +): Promise { + const db = database(); + const results = await db + .select() + .from(schema.tournaments) + .where( + and( + eq(schema.tournaments.sportId, sportId), + eq(schema.tournaments.name, name), + eq(schema.tournaments.year, year) + ) + ) + .limit(1); + return results[0] ?? null; +} + +export async function upsertTournament( + data: NewTournament +): Promise { + if (!data.sportId || !data.name || !data.year) { + throw new Error("sportId, name, and year are required for upsert"); + } + + const existing = await findTournamentBySportNameYear( + data.sportId, + data.name, + data.year + ); + + if (existing) { + return existing; + } + + return await createTournament(data); +} + +export async function updateTournamentStatus( + id: string, + status: TournamentStatus +): Promise { + const db = database(); + const [tournament] = await db + .update(schema.tournaments) + .set({ status, updatedAt: new Date() }) + .where(eq(schema.tournaments.id, id)) + .returning(); + return tournament; +}