Fix getEventsForDates to include bracket events by game scheduledAt
The previous implementation only matched on scoringEvents.eventDate, which is often null for bracket events where individual game times are set on playoffMatchGames.scheduledAt instead. Switched to a two-step query mirroring getUpcomingEventsForDraftedParticipants: 1. selectDistinct event IDs via left joins through playoffMatches → playoffMatchGames, matching where eventDate IN dates OR any game's scheduledAt falls within the date range's timestamp bounds 2. findMany on the matched IDs with sportsSeason + sport relations Updated tests to cover the two-step flow, leftJoin usage, and the scheduledAt timestamp bounds appearing in the where clause. https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
This commit is contained in:
parent
4044a6372e
commit
32ae32ab6a
2 changed files with 174 additions and 59 deletions
|
|
@ -3,12 +3,31 @@
|
|||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ── DB mock ─────────────────────────────────────────────────────────────────
|
||||
// ── DB mock ──────────────────────────────────────────────────────────────────
|
||||
// selectDistinct returns a fluent chain; findMany is used in step 2.
|
||||
const mockFindMany = vi.fn();
|
||||
const mockSelectChainBase = {
|
||||
from: vi.fn(),
|
||||
leftJoin: vi.fn(),
|
||||
where: vi.fn(),
|
||||
};
|
||||
|
||||
function makeSelectChain(rows: { id: string }[]) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(rows),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
const mockSelectDistinct = vi.fn();
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findMany: mockFindMany },
|
||||
},
|
||||
selectDistinct: mockSelectDistinct,
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
|
|
@ -17,11 +36,20 @@ vi.mock("~/database/context", () => ({
|
|||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
scoringEvents: {
|
||||
id: "se.id",
|
||||
eventDate: "se.event_date",
|
||||
eventType: "se.event_type",
|
||||
eventStartsAt: "se.event_starts_at",
|
||||
createdAt: "se.created_at",
|
||||
},
|
||||
playoffMatches: {
|
||||
scoringEventId: "pm.scoring_event_id",
|
||||
id: "pm.id",
|
||||
},
|
||||
playoffMatchGames: {
|
||||
playoffMatchId: "pmg.playoff_match_id",
|
||||
scheduledAt: "pmg.scheduled_at",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
|
|
@ -36,12 +64,11 @@ vi.mock("drizzle-orm", () => ({
|
|||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||
}));
|
||||
|
||||
// Must be imported after mocks are registered
|
||||
import { getEventsForDates } from "../scoring-event";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeScoringEventRow(overrides: Partial<{
|
||||
function makeEventRow(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
eventDate: string | null;
|
||||
|
|
@ -68,33 +95,46 @@ function makeScoringEventRow(overrides: Partial<{
|
|||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getEventsForDates", () => {
|
||||
beforeEach(() => {
|
||||
mockFindMany.mockReset();
|
||||
vi.clearAllMocks();
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([]));
|
||||
});
|
||||
|
||||
it("returns an empty array when no dates are provided", async () => {
|
||||
it("returns empty array and skips DB when no dates given", async () => {
|
||||
const result = await getEventsForDates([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSelectDistinct).not.toHaveBeenCalled();
|
||||
expect(mockFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty array when selectDistinct finds no matching event IDs", async () => {
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([]));
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps database rows to DashboardScoringEvent shape", async () => {
|
||||
const row = makeScoringEventRow({
|
||||
id: "e1",
|
||||
name: "Conference Finals",
|
||||
eventDate: "2026-03-20",
|
||||
eventType: "playoff_game",
|
||||
isComplete: false,
|
||||
sportsSeasonId: "ss-1",
|
||||
sportsSeason: {
|
||||
name: "2026 NBA Playoffs",
|
||||
sport: { name: "NBA", slug: "nba" },
|
||||
},
|
||||
});
|
||||
mockFindMany.mockResolvedValueOnce([row]);
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
makeEventRow({
|
||||
id: "e1",
|
||||
name: "Conference Finals",
|
||||
eventDate: "2026-03-20",
|
||||
eventType: "playoff_game",
|
||||
isComplete: false,
|
||||
sportsSeasonId: "ss-1",
|
||||
sportsSeason: {
|
||||
name: "2026 NBA Playoffs",
|
||||
sport: { name: "NBA", slug: "nba" },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
|
|
@ -113,9 +153,13 @@ describe("getEventsForDates", () => {
|
|||
});
|
||||
|
||||
it("returns events for multiple dates", async () => {
|
||||
const todayRow = makeScoringEventRow({ id: "e1", eventDate: "2026-03-20" });
|
||||
const tomorrowRow = makeScoringEventRow({ id: "e2", eventDate: "2026-03-21" });
|
||||
mockFindMany.mockResolvedValueOnce([todayRow, tomorrowRow]);
|
||||
mockSelectDistinct.mockReturnValue(
|
||||
makeSelectChain([{ id: "e1" }, { id: "e2" }])
|
||||
);
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
makeEventRow({ id: "e1", eventDate: "2026-03-20" }),
|
||||
makeEventRow({ id: "e2", eventDate: "2026-03-21" }),
|
||||
]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||
|
||||
|
|
@ -124,12 +168,14 @@ describe("getEventsForDates", () => {
|
|||
});
|
||||
|
||||
it("includes completed events with isComplete: true", async () => {
|
||||
const completedRow = makeScoringEventRow({
|
||||
id: "e-done",
|
||||
isComplete: true,
|
||||
completedAt: new Date("2026-03-20T18:00:00Z"),
|
||||
});
|
||||
mockFindMany.mockResolvedValueOnce([completedRow]);
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e-done" }]));
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
makeEventRow({
|
||||
id: "e-done",
|
||||
isComplete: true,
|
||||
completedAt: new Date("2026-03-20T18:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
|
|
@ -139,46 +185,75 @@ describe("getEventsForDates", () => {
|
|||
|
||||
it("includes eventStartsAt when present", async () => {
|
||||
const starts = new Date("2026-03-20T19:30:00Z");
|
||||
const row = makeScoringEventRow({ id: "e1", eventStartsAt: starts });
|
||||
mockFindMany.mockResolvedValueOnce([row]);
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
makeEventRow({ id: "e1", eventStartsAt: starts }),
|
||||
]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result[0].eventStartsAt).toEqual(starts);
|
||||
});
|
||||
|
||||
it("passes both dates as an inArray condition to the query", async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
it("uses two DB calls: selectDistinct then findMany", async () => {
|
||||
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]);
|
||||
|
||||
await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||
await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
const callArgs = mockFindMany.mock.calls[0][0];
|
||||
// The where clause should contain an inArray condition with both dates
|
||||
const whereStr = JSON.stringify(callArgs.where);
|
||||
expect(whereStr).toContain("2026-03-20");
|
||||
expect(whereStr).toContain("2026-03-21");
|
||||
expect(mockSelectDistinct).toHaveBeenCalledOnce();
|
||||
expect(mockFindMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("excludes schedule_event types via inArray condition", async () => {
|
||||
it("passes event IDs from step 1 into findMany in step 2", async () => {
|
||||
mockSelectDistinct.mockReturnValue(
|
||||
makeSelectChain([{ id: "abc" }, { id: "def" }])
|
||||
);
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
const callArgs = mockFindMany.mock.calls[0][0];
|
||||
const whereStr = JSON.stringify(callArgs.where);
|
||||
// The allowed event types should be present
|
||||
expect(whereStr).toContain("playoff_game");
|
||||
expect(whereStr).toContain("major_tournament");
|
||||
expect(whereStr).toContain("final_standings");
|
||||
// schedule_event should not appear
|
||||
expect(whereStr).not.toContain("schedule_event");
|
||||
const findManyArgs = mockFindMany.mock.calls[0][0];
|
||||
const whereStr = JSON.stringify(findManyArgs.where);
|
||||
expect(whereStr).toContain("abc");
|
||||
expect(whereStr).toContain("def");
|
||||
});
|
||||
|
||||
it("returns empty array when DB returns no rows", async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => {
|
||||
const chain = makeSelectChain([]);
|
||||
mockSelectDistinct.mockReturnValue(chain);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(chain.leftJoin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => {
|
||||
const chain = makeSelectChain([]);
|
||||
mockSelectDistinct.mockReturnValue(chain);
|
||||
|
||||
await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||
|
||||
// The where clause is the first (and only) arg to chain.where
|
||||
const whereArg = JSON.stringify((chain.where as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||
// eventDate inArray check
|
||||
expect(whereArg).toContain("2026-03-20");
|
||||
expect(whereArg).toContain("2026-03-21");
|
||||
// timestamp bounds for scheduledAt
|
||||
expect(whereArg).toContain("T00:00:00.000Z");
|
||||
expect(whereArg).toContain("T23:59:59.999Z");
|
||||
});
|
||||
|
||||
it("excludes schedule_event type via the where clause", async () => {
|
||||
const chain = makeSelectChain([]);
|
||||
mockSelectDistinct.mockReturnValue(chain);
|
||||
|
||||
await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
const whereArg = JSON.stringify((chain.where as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||
expect(whereArg).toContain("playoff_game");
|
||||
expect(whereArg).toContain("major_tournament");
|
||||
expect(whereArg).toContain("final_standings");
|
||||
expect(whereArg).not.toContain("schedule_event");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -562,6 +562,11 @@ export interface DashboardScoringEvent {
|
|||
* Get all scoring events (excluding schedule_event type) for the given dates,
|
||||
* joined with their sports season and sport info. Used for the admin dashboard
|
||||
* "Games to Score" widget.
|
||||
*
|
||||
* An event is included if EITHER its own eventDate falls on one of the given
|
||||
* dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of
|
||||
* those dates. This ensures bracket events where the admin set dates on
|
||||
* individual games (rather than the event itself) are still surfaced.
|
||||
*/
|
||||
export async function getEventsForDates(
|
||||
dates: string[],
|
||||
|
|
@ -571,15 +576,50 @@ export async function getEventsForDates(
|
|||
|
||||
const db = providedDb || database();
|
||||
|
||||
// Build timestamp bounds covering the full span of the requested dates so we
|
||||
// can range-check the playoffMatchGames.scheduledAt timestamp column.
|
||||
const sortedDates = [...dates].sort();
|
||||
const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z");
|
||||
const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z");
|
||||
|
||||
// Step 1: collect distinct event IDs where the event date OR any game's
|
||||
// scheduledAt falls within the requested dates.
|
||||
const rows = await db
|
||||
.selectDistinct({ id: schema.scoringEvents.id })
|
||||
.from(schema.scoringEvents)
|
||||
.leftJoin(
|
||||
schema.playoffMatches,
|
||||
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
|
||||
)
|
||||
.leftJoin(
|
||||
schema.playoffMatchGames,
|
||||
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.scoringEvents.eventType, [
|
||||
"playoff_game",
|
||||
"major_tournament",
|
||||
"final_standings",
|
||||
]),
|
||||
or(
|
||||
inArray(schema.scoringEvents.eventDate, dates),
|
||||
and(
|
||||
isNotNull(schema.playoffMatchGames.scheduledAt),
|
||||
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
|
||||
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const eventIds = rows.map((r) => r.id);
|
||||
|
||||
// Step 2: fetch the matched events with sport season + sport info.
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
inArray(schema.scoringEvents.eventDate, dates),
|
||||
inArray(schema.scoringEvents.eventType, [
|
||||
"playoff_game",
|
||||
"major_tournament",
|
||||
"final_standings",
|
||||
])
|
||||
),
|
||||
where: inArray(schema.scoringEvents.id, eventIds),
|
||||
orderBy: [
|
||||
asc(schema.scoringEvents.eventDate),
|
||||
asc(schema.scoringEvents.eventStartsAt),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue