brackt/app/models/__tests__/upcoming-calendar.test.ts
Chris Parsons dc13d99f39
Add event_starts_at timestamp to scoring events for sub-day ordering (#146)
- New `event_starts_at timestamptz` column on `scoring_events`; backfilled
  from `event_date` (midnight UTC) via migration for all existing rows
- Admin create/edit forms consolidated from separate Date + Time inputs into
  a single `datetime-local` field; server derives `eventDate` from the UTC
  date portion of `eventStartsAt` so calendar-window queries continue to work
- Edit form pre-fill computed client-side via useEffect to avoid server-timezone
  mismatch for non-UTC admins
- Sort fix: replaced `eventDate ?? earliestGameTime.split("T")[0]` with
  `toEventSortKey` helper that prefers `earliestGameTime ?? eventDate` so
  bracket events with only per-game timestamps sort in the correct calendar position
- DB sort queries updated to use `eventStartsAt` as a tiebreaker after `eventDate`
- F1/IndyCar/golf events now populate `earliestGameTime` from `eventStartsAt`,
  giving time display in `UpcomingCalendarPanel`
- Bulk import gains optional `HH:MM` (UTC) third column
- New unit tests for `toEventSortKey` and `eventStartsAt` model behavior

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 10:22:42 -07:00

503 lines
21 KiB
TypeScript

/**
* Tests for getUpcomingEventsForDraftedParticipants logic.
*
* We test the exported function indirectly by exercising the pure
* business-logic paths (all-compete vs bracket, date filtering,
* deduplication, empty inputs) using a vi.mock of the database context
* so no real DB is required.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ────────────────────────────────────────────────────────────────
// Must be declared before the import that triggers the module graph.
const mockDb = {
query: {
scoringEvents: { findMany: vi.fn() },
},
selectDistinct: vi.fn(),
};
vi.mock("~/database/context", () => ({
database: () => mockDb,
}));
vi.mock("~/database/schema", () => ({
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
playoffMatches: { scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number" },
playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" },
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
asc: (col: unknown) => ({ type: "asc", col }),
desc: (col: unknown) => ({ type: "desc", col }),
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
or: (...args: unknown[]) => ({ type: "or", args }),
inArray: (col: unknown, arr: unknown) => ({ type: "inArray", col, arr }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
}));
import { getUpcomingEventsForDraftedParticipants } from "../scoring-event";
// ── Helpers ────────────────────────────────────────────────────────────────
const DATE_FROM = new Date("2025-04-01");
const DATE_TO = new Date("2025-04-30");
const SPORTS_SEASON_ID = "ss-1";
function makeParticipant(id: string, name: string) {
return { id, name };
}
function makeScoringEvent(overrides: Partial<{
id: string; name: string; eventDate: string | null;
eventStartsAt: Date | null;
eventType: string; sportsSeasonId: string; isComplete: boolean;
}> = {}) {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "Test Event",
eventDate: overrides.eventDate ?? "2025-04-10",
eventStartsAt: overrides.eventStartsAt ?? null,
eventType: overrides.eventType ?? "schedule_event",
sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID,
isComplete: overrides.isComplete ?? false,
};
}
// ── All-compete patterns ───────────────────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => {
beforeEach(() => {
vi.clearAllMocks();
// selectDistinct won't be called for all-compete
mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
});
it("returns [] when draftedParticipants is empty", async () => {
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", [], DATE_FROM, DATE_TO
);
expect(result).toEqual([]);
expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
});
it("returns all events with all drafted participants attached", async () => {
const participants = [makeParticipant("p1", "Verstappen"), makeParticipant("p2", "Hamilton")];
const events = [makeScoringEvent({ id: "e1", name: "Australian GP" })];
mockDb.query.scoringEvents.findMany.mockResolvedValue(events);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Australian GP");
expect(result[0].relevantParticipants).toEqual(participants);
});
it("works for qualifying_points pattern", async () => {
const participants = [makeParticipant("g1", "Scheffler")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([makeScoringEvent({ id: "e1", name: "Masters" })]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "qualifying_points", participants, DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
});
it("unknown pattern (e.g. golf_qualifying_points) falls through to bracket path, not all-compete", async () => {
// golf_qualifying_points is a simulatorType, not a scoringPattern.
// Unknown patterns are treated as bracket sports (selectDistinct path).
const participants = [makeParticipant("g1", "McIlroy")];
const selectChain = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) };
mockDb.selectDistinct.mockReturnValue(selectChain);
await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "golf_qualifying_points", participants, DATE_FROM, DATE_TO
);
// Should NOT call the all-compete findMany path
expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
// Should call the bracket selectDistinct path
expect(mockDb.selectDistinct).toHaveBeenCalled();
});
it("returns multiple events sorted by eventDate", async () => {
const participants = [makeParticipant("p1", "Verstappen")];
const events = [
makeScoringEvent({ id: "e2", name: "Miami GP", eventDate: "2025-04-20" }),
makeScoringEvent({ id: "e1", name: "Australian GP", eventDate: "2025-04-06" }),
];
// findMany is ordered by asc(eventDate) in the real query; simulate same order
mockDb.query.scoringEvents.findMany.mockResolvedValue([events[1], events[0]]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].name).toBe("Australian GP");
expect(result[1].name).toBe("Miami GP");
});
});
// ── Bracket patterns ───────────────────────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
function makeSelectChain(rows: unknown[]) {
const chain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue(rows),
};
return chain;
}
beforeEach(() => {
vi.clearAllMocks();
});
it("returns [] when draftedParticipants is empty", async () => {
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket", [], DATE_FROM, DATE_TO
);
expect(result).toEqual([]);
expect(mockDb.selectDistinct).not.toHaveBeenCalled();
});
it("returns one event with one relevant participant (participant1)", async () => {
const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "bayern",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("UCL QF");
expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
});
it("returns one event when both participants are drafted (no duplication)", async () => {
// Two rows for the same event — the DB query uses selectDistinct on event columns
// but we may still get two rows if the WHERE matches on both participants.
// The grouping logic must deduplicate the event itself.
const rows = [
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].relevantParticipants).toHaveLength(2);
const names = result[0].relevantParticipants.map((p) => p.name);
expect(names).toContain("Man City");
expect(names).toContain("Arsenal");
});
it("returns multiple events when picks are in different matches", async () => {
const rows = [
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "arsenal", participant2Id: "psg",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
DATE_FROM, DATE_TO
);
// Both matches are in the same event → one result with both picks
expect(result).toHaveLength(1);
expect(result[0].relevantParticipants).toHaveLength(2);
});
it("only includes drafted participants in relevantParticipants, not opponents", async () => {
const rows = [{
id: "e1", name: "UCL QF", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "real-madrid",
round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
// Real Madrid (the opponent) should NOT appear
expect(result[0].relevantParticipants.find((p) => p.id === "real-madrid")).toBeUndefined();
});
it("works for ucl_bracket pattern", async () => {
mockDb.selectDistinct.mockReturnValue(makeSelectChain([{
id: "e1", name: "UCL SF", eventDate: "2025-04-29",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null,
}]));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "ucl_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
});
it("includes events with null eventDate when they have games scheduled within the window", async () => {
// Real-world case: admin sets game times on individual games (scheduledAt)
// but leaves scoringEvent.eventDate null — the event must still appear.
const rows = [{
id: "e1", name: "UCL QF",
eventDate: null, // ← no date on the event itself
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("UCL QF");
expect(result[0].eventDate).toBeNull();
expect(result[0].relevantParticipants).toEqual([{ id: "barcelona", name: "Barcelona" }]);
});
it("surfaces earliestGameTime from scheduledAt when eventDate is null", async () => {
const gameTime = new Date("2025-04-09T14:00:00.000Z");
const rows = [{
id: "e1", name: "UCL QF",
eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBe(gameTime.toISOString());
expect(result[0].eventDate).toBeNull();
});
it("picks the earliest scheduledAt when multiple game rows exist for an event", async () => {
const gameTime1 = new Date("2025-04-09T13:00:00.000Z");
const gameTime2 = new Date("2025-04-09T15:00:00.000Z");
const rows = [
{
id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime2,
},
{
id: "e1", name: "UCL QF", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime1,
},
];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
// Should use the earlier time
expect(result[0].earliestGameTime).toBe(gameTime1.toISOString());
});
it("sets matchLabel to round+game when single game key (non-redundant)", async () => {
const rows = [{
id: "e1", name: "PDC World Championship", eventDate: "2025-04-09",
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "man-city", participant2Id: "arsenal",
round: "Semifinals", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Semifinals Game #1");
});
it("omits redundant round prefix when round name matches event name", async () => {
// e.g. event named "Knockout Stage", round also "Knockout Stage", single game
// → show "Game #1" rather than the redundant "Knockout Stage Game #1"
const rows = [{
id: "e1", name: "Knockout Stage", eventDate: null,
eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
participant1Id: "barcelona", participant2Id: "sporting",
round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
}];
mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("barcelona", "Barcelona")],
DATE_FROM, DATE_TO
);
expect(result[0].matchLabel).toBe("Game #1");
});
it("all-compete events have null earliestGameTime and matchLabel", async () => {
const participants = [makeParticipant("p1", "Verstappen")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "Australian GP" }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBeNull();
expect(result[0].matchLabel).toBeNull();
});
});
// ── eventStartsAt — all-compete sports ────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — eventStartsAt for all-compete sports", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.selectDistinct.mockReturnValue({
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
});
});
it("populates earliestGameTime from eventStartsAt when set", async () => {
const raceStart = new Date("2025-04-06T14:00:00.000Z");
const participants = [makeParticipant("p1", "Verstappen")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "Australian GP", eventStartsAt: raceStart }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBe(raceStart.toISOString());
});
it("leaves earliestGameTime null when eventStartsAt is not set", async () => {
const participants = [makeParticipant("p1", "Verstappen")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "Australian GP", eventStartsAt: null }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBeNull();
});
it("works for qualifying_points pattern with eventStartsAt", async () => {
const majorStart = new Date("2025-04-10T12:00:00.000Z");
const participants = [makeParticipant("g1", "Scheffler")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "The Masters", eventStartsAt: majorStart }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "qualifying_points", participants, DATE_FROM, DATE_TO
);
expect(result[0].earliestGameTime).toBe(majorStart.toISOString());
});
it("earliestGameTime from eventStartsAt is used as sort key over eventDate", async () => {
// Event on Apr 6, game starts at 14:00 UTC — both should be reflected in earliestGameTime
const raceStart = new Date("2025-04-06T14:00:00.000Z");
const participants = [makeParticipant("p1", "Hamilton")];
mockDb.query.scoringEvents.findMany.mockResolvedValue([
makeScoringEvent({ id: "e1", name: "Bahrain GP", eventDate: "2025-04-06", eventStartsAt: raceStart }),
]);
const result = await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
);
// earliestGameTime carries the full timestamp so the sort key has time precision
expect(result[0].earliestGameTime).toBe("2025-04-06T14:00:00.000Z");
expect(result[0].eventDate).toBe("2025-04-06");
});
});
// ── Bracket: misc ──────────────────────────────────────────────────────────
describe("getUpcomingEventsForDraftedParticipants — bracket misc", () => {
function makeSelectChain(rows: unknown[]) {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue(rows),
};
}
beforeEach(() => vi.clearAllMocks());
it("uses leftJoin so events without any games are still considered", async () => {
const chain = makeSelectChain([]);
mockDb.selectDistinct.mockReturnValue(chain);
await getUpcomingEventsForDraftedParticipants(
SPORTS_SEASON_ID, "playoff_bracket",
[makeParticipant("man-city", "Man City")],
DATE_FROM, DATE_TO
);
expect(chain.leftJoin).toHaveBeenCalled();
});
});