brackt/app/models/__tests__/scoring-event-dashboard.test.ts
Chris Parsons c5ccee2225
Show events on multiple dates when games span requested date range (#254)
* Fix admin Games to Score panel missing bracket events

The panel was silently dropping bracket events in two cases:

1. When a bracket event had `scoringEvents.eventDate` set to a future
   round date (e.g. "April 5") but an individual game's `scheduledAt`
   was today, the old dedup code picked `eventDate` first and the
   component filter `displayDate === today` never matched.

2. When an event had games on both today and tomorrow, whichever row
   the unordered step-1 query returned first "won" — potentially hiding
   a today game from the today tab entirely.

Fix: replace the `Map<id, firstDate>` deduplication with
`Map<id, Set<date>>` that collects every requested date an event
qualifies for (via either `eventDate` or `gameDate`). The return uses
`flatMap` to emit one entry per (event, date) pair, so events with
games on multiple days now appear correctly in both tabs.

https://claude.ai/code/session_01QkFxCF4xmKjdVCNPysWDML

* Fix lint errors: replace .sort() with .toSorted() and remove non-null assertion

- Replace Array#sort() with Array#toSorted() in two places (unicorn rule)
- Remove non-null assertion on Map#get(); use early continue instead

https://claude.ai/code/session_01QkFxCF4xmKjdVCNPysWDML

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 13:39:03 -07:00

324 lines
12 KiB
TypeScript

/**
* Tests for getEventsForDates — the admin dashboard "Games to Score" query.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ──────────────────────────────────────────────────────────────────
// select returns a fluent chain; findMany is used in step 2.
const mockFindMany = vi.fn();
function makeSelectChain(rows: { id: string; eventDate?: string | null; gameDate?: string | null }[]) {
const chain = {
from: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(rows),
};
return chain;
}
const mockSelect = vi.fn();
const mockDb = {
query: {
scoringEvents: { findMany: mockFindMany },
},
select: mockSelect,
};
vi.mock("~/database/context", () => ({
database: () => mockDb,
}));
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", () => ({
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 }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
}));
import { getEventsForDates } from "../scoring-event";
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeEventRow(overrides: Partial<{
id: string;
name: string;
eventDate: string | null;
eventStartsAt: Date | null;
eventType: string;
isComplete: boolean;
completedAt: Date | null;
sportsSeasonId: string;
sportsSeason: { name: string; sport: { name: string; slug: string | null } };
}> = {}) {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "Test Event",
eventDate: overrides.eventDate ?? "2026-03-20",
eventStartsAt: overrides.eventStartsAt ?? null,
eventType: overrides.eventType ?? "playoff_game",
isComplete: overrides.isComplete ?? false,
completedAt: overrides.completedAt ?? null,
sportsSeasonId: overrides.sportsSeasonId ?? "season-1",
sportsSeason: overrides.sportsSeason ?? {
name: "2026 NFL Playoffs",
sport: { name: "NFL", slug: "nfl" },
},
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("getEventsForDates", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelect.mockReturnValue(makeSelectChain([]));
});
it("returns empty array and skips DB when no dates given", async () => {
const result = await getEventsForDates([]);
expect(result).toEqual([]);
expect(mockSelect).not.toHaveBeenCalled();
expect(mockFindMany).not.toHaveBeenCalled();
});
it("returns empty array when select finds no matching event IDs", async () => {
mockSelect.mockReturnValue(makeSelectChain([]));
const result = await getEventsForDates(["2026-03-20"]);
expect(result).toEqual([]);
expect(mockFindMany).not.toHaveBeenCalled();
});
it("maps database rows to DashboardScoringEvent shape", async () => {
mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }]));
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"]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: "e1",
name: "Conference Finals",
eventDate: "2026-03-20",
eventType: "playoff_game",
isComplete: false,
sportsSeasonId: "ss-1",
sportsSeasonName: "2026 NBA Playoffs",
sportName: "NBA",
sportSlug: "nba",
});
});
it("returns events for multiple dates", async () => {
mockSelect.mockReturnValue(
makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }, { id: "e2", eventDate: "2026-03-21", gameDate: null }])
);
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"]);
expect(result).toHaveLength(2);
expect(result.map((e) => e.id)).toEqual(["e1", "e2"]);
});
it("includes completed events with isComplete: true", async () => {
mockSelect.mockReturnValue(makeSelectChain([{ id: "e-done", eventDate: "2026-03-20", gameDate: null }]));
mockFindMany.mockResolvedValueOnce([
makeEventRow({
id: "e-done",
isComplete: true,
completedAt: new Date("2026-03-20T18:00:00Z"),
}),
]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].isComplete).toBe(true);
expect(result[0].completedAt).toBeInstanceOf(Date);
});
it("includes eventStartsAt when present", async () => {
const starts = new Date("2026-03-20T19:30:00Z");
mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }]));
mockFindMany.mockResolvedValueOnce([
makeEventRow({ id: "e1", eventStartsAt: starts }),
]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].eventStartsAt).toEqual(starts);
});
it("uses two DB calls: select then findMany", async () => {
mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }]));
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]);
await getEventsForDates(["2026-03-20"]);
expect(mockSelect).toHaveBeenCalledOnce();
expect(mockFindMany).toHaveBeenCalledOnce();
});
it("passes event IDs from step 1 into findMany in step 2", async () => {
mockSelect.mockReturnValue(
makeSelectChain([{ id: "abc", eventDate: "2026-03-20", gameDate: null }, { id: "def", eventDate: "2026-03-20", gameDate: null }])
);
mockFindMany.mockResolvedValueOnce([]);
await getEventsForDates(["2026-03-20"]);
const findManyArgs = mockFindMany.mock.calls[0][0];
const whereStr = JSON.stringify(findManyArgs.where);
expect(whereStr).toContain("abc");
expect(whereStr).toContain("def");
});
it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => {
const chain = makeSelectChain([]);
mockSelect.mockReturnValue(chain);
await getEventsForDates(["2026-03-20"]);
expect(chain.leftJoin).toHaveBeenCalled();
});
it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => {
const chain = makeSelectChain([]);
mockSelect.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([]);
mockSelect.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");
});
it("sets displayDate from eventDate when present", async () => {
mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }]));
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: "2026-03-20" })]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].displayDate).toBe("2026-03-20");
});
it("sets displayDate from gameDate when eventDate is null", async () => {
mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: null, gameDate: "2026-03-20" }]));
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: null })]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].displayDate).toBe("2026-03-20");
});
it("uses gameDate over eventDate when eventDate is outside requested dates", async () => {
// Bracket event: eventDate is the round's overall date (not in range),
// but an individual game is scheduled on the requested date.
mockSelect.mockReturnValue(
makeSelectChain([{ id: "e1", eventDate: "2026-04-05", gameDate: "2026-03-20" }])
);
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: "2026-04-05" })]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result).toHaveLength(1);
expect(result[0].displayDate).toBe("2026-03-20");
});
it("returns two entries for an event with games on both requested dates", async () => {
// Same event has a game today AND a game tomorrow — should appear in both tabs.
mockSelect.mockReturnValue(
makeSelectChain([
{ id: "e1", eventDate: null, gameDate: "2026-03-20" },
{ id: "e1", eventDate: null, gameDate: "2026-03-21" },
])
);
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: null })]);
const result = await getEventsForDates(["2026-03-20", "2026-03-21"]);
expect(result).toHaveLength(2);
const displayDates = result.map((e) => e.displayDate).toSorted();
expect(displayDates).toEqual(["2026-03-20", "2026-03-21"]);
// Both entries share the same event id
expect(result[0].id).toBe("e1");
expect(result[1].id).toBe("e1");
});
it("deduplicates when multiple matches in same bracket have games on the same day", async () => {
// Two rows for the same event on the same gameDate (e.g. two matches in today's bracket)
mockSelect.mockReturnValue(
makeSelectChain([
{ id: "e1", eventDate: null, gameDate: "2026-03-20" },
{ id: "e1", eventDate: null, gameDate: "2026-03-20" },
])
);
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: null })]);
const result = await getEventsForDates(["2026-03-20"]);
// Should appear only once, not twice
expect(result).toHaveLength(1);
expect(result[0].displayDate).toBe("2026-03-20");
});
});