brackt/app/services/__tests__/draft-email.server.test.ts
Chris Parsons b8b58ec9f8
Add draft email notifications feature (#459)
* Add on-the-clock email notifications and fix push notification modal

- Add `draft_email_notifications_enabled` column to users table
- New `sendOnTheClockEmail` service sends an email when a user's turn arrives
  in a draft; detects back-to-back picks and sends one combined email instead
  of two; skips notification if the team has autodraft enabled
- Email is triggered after every manual pick (make-pick route) and every
  timer-expiry pick (executeAutoPick) once the full autodraft chain settles,
  so the notified user is always the first human on the clock
- Add email notification toggle to the user settings Notifications section
- Fix push notification dropdown in the draft room: the bare absolute div is
  replaced with a Radix Popover so clicking outside dismisses it without
  toggling notifications off
- Rename draft room label from "Pick Notifications" to "Push Notifications"
  to distinguish from the new email notifications

https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA

* Address code review feedback on email notification feature

- sendOnTheClockEmail now fetches season (and currentPickNumber) internally,
  removing the blocking postChainSeason pre-fetch from both make-pick.ts and
  executeAutoPick; callers are now true fire-and-forget with no IIFE needed
- Parallel Promise.all for team, autodraftSettings, and league queries reduces
  sequential DB round-trips from 5 to 3
- Remove team.ownerId type cast; assign to local after the null guard
- Combine the two calculatePickInfo calls for the same pick into one
- Fix popoverOpen/enabled divergence: remove the {enabled && ...} guard on
  PopoverContent so popoverOpen is the sole visibility control
- Unify NotificationsSection feedback pattern: both Discord and email sections
  now read success/error directly from their respective fetcher data via a
  shared feedbackFromFetcher helper; removes the success/error props and the
  verbose cast that was used for email
- Add 13 unit tests for sendOnTheClockEmail covering: early-exit paths
  (no season, past totalPicks, no owner, notifications disabled, autodraft
  enabled, no league), single vs back-to-back subject selection, draft room
  link presence, error logging without throwing, and parallel query execution

https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 15:35:43 -07:00

247 lines
8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/lib/email.server", () => ({
sendEmail: vi.fn().mockResolvedValue({ error: null }),
wrapInEmailTemplate: vi.fn((content: string) => `<html>${content}</html>`),
emailParagraph: vi.fn((html: string) => `<p>${html}</p>`),
emailButton: vi.fn((href: string, text: string) => `<a href="${href}">${text}</a>`),
escapeHtml: vi.fn((s: string) => s),
}));
import { sendOnTheClockEmail } from "../draft-email.server";
import { sendEmail } from "~/lib/email.server";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const TEAM_ID = "team-1";
const TEAM_ID_2 = "team-2";
const OWNER_ID = "owner-1";
/** 2-team, 4-round draft: picks 1,2,3,4,5,6,7,8 */
const DRAFT_SLOTS_2_TEAM = [
{ teamId: TEAM_ID, draftOrder: 1 },
{ teamId: TEAM_ID_2, draftOrder: 2 },
];
function makeSeason(currentPickNumber: number, draftRounds = 4) {
return {
id: SEASON_ID,
leagueId: LEAGUE_ID,
currentPickNumber,
draftRounds,
};
}
function makeOwner(emailNotificationsEnabled = true) {
return {
id: OWNER_ID,
email: "owner@example.com",
draftEmailNotificationsEnabled: emailNotificationsEnabled,
};
}
function makeMockDb(overrides: {
season?: object | null;
team?: object | null;
autodraft?: object | null;
league?: object | null;
owner?: object | null;
} = {}) {
const season = "season" in overrides ? overrides.season : makeSeason(1);
const team = "team" in overrides ? overrides.team : { id: TEAM_ID, name: "Team One", ownerId: OWNER_ID };
const autodraft = "autodraft" in overrides ? overrides.autodraft : null;
const league = "league" in overrides ? overrides.league : { id: LEAGUE_ID, name: "Test League" };
const owner = "owner" in overrides ? overrides.owner : makeOwner();
return {
query: {
seasons: { findFirst: vi.fn().mockResolvedValue(season) },
teams: { findFirst: vi.fn().mockResolvedValue(team) },
autodraftSettings: { findFirst: vi.fn().mockResolvedValue(autodraft) },
leagues: { findFirst: vi.fn().mockResolvedValue(league) },
users: { findFirst: vi.fn().mockResolvedValue(owner) },
},
};
}
const BASE_PARAMS = {
seasonId: SEASON_ID,
totalTeams: 2,
draftSlots: DRAFT_SLOTS_2_TEAM,
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
process.env.APP_URL = "https://test.brackt.com";
});
afterEach(() => {
delete process.env.APP_URL;
});
describe("sendOnTheClockEmail", () => {
it("sends an email with the correct subject and recipient", async () => {
const db = makeMockDb({ season: makeSeason(1) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "owner@example.com",
subject: "You're on the clock in Test League",
})
);
});
it("returns early when season is not found", async () => {
const db = makeMockDb({ season: null });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("returns early when currentPickNumber exceeds totalPicks", async () => {
// 2 teams × 4 rounds = 8 total picks; pick 9 is past the end
const db = makeMockDb({ season: makeSeason(9, 4) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("returns early when the team has no owner", async () => {
const db = makeMockDb({ team: { id: TEAM_ID, name: "Team One", ownerId: null } });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("returns early when owner has email notifications disabled", async () => {
const db = makeMockDb({ owner: makeOwner(false) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("returns early when autodraft is enabled for the team", async () => {
const db = makeMockDb({ autodraft: { isEnabled: true } });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("returns early when league is not found", async () => {
const db = makeMockDb({ league: null });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).not.toHaveBeenCalled();
});
it("sends back-to-back subject when the same team picks consecutively (snake turnaround)", async () => {
// In a 2-team snake draft, pick 2 (team 2 forward) is followed by pick 3
// (team 2 again on the reversal). Team 2 picks back-to-back.
const db = makeMockDb({
season: makeSeason(2),
team: { id: TEAM_ID_2, name: "Team Two", ownerId: OWNER_ID },
});
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: "You have back-to-back picks in Test League",
})
);
});
it("sends a regular subject when the next pick goes to a different team", async () => {
// Pick 1 is team 1; pick 2 is team 2 — not consecutive for team 1
const db = makeMockDb({ season: makeSeason(1) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: "You're on the clock in Test League",
})
);
});
it("does not treat the final pick as consecutive even when it is the last slot", async () => {
// Pick 8 is the last pick (2 teams × 4 rounds). No pick 9, so not consecutive.
const db = makeMockDb({ season: makeSeason(8, 4) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: expect.stringContaining("on the clock"),
})
);
});
it("includes a link to the draft room in the email", async () => {
const db = makeMockDb({ season: makeSeason(1) });
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
const call = vi.mocked(sendEmail).mock.calls[0][0];
expect(call.html).toContain(
`https://test.brackt.com/leagues/${LEAGUE_ID}/draft/${SEASON_ID}`
);
});
it("logs an error but does not throw when sendEmail fails", async () => {
const db = makeMockDb({ season: makeSeason(1) });
vi.mocked(sendEmail).mockResolvedValueOnce({ error: new Error("network failure") });
await expect(
sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never })
).resolves.toBeUndefined();
});
it("fetches team, autodraft settings, and league in parallel", async () => {
const order: string[] = [];
const db = makeMockDb({ season: makeSeason(1) });
db.query.teams.findFirst = vi.fn().mockImplementation(async () => {
order.push("team");
return { id: TEAM_ID, name: "Team One", ownerId: OWNER_ID };
});
db.query.autodraftSettings.findFirst = vi.fn().mockImplementation(async () => {
order.push("autodraft");
return null;
});
db.query.leagues.findFirst = vi.fn().mockImplementation(async () => {
order.push("league");
return { id: LEAGUE_ID, name: "Test League" };
});
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
// All three should be called; order may vary since they run in parallel
expect(order).toContain("team");
expect(order).toContain("autodraft");
expect(order).toContain("league");
expect(db.query.teams.findFirst).toHaveBeenCalledTimes(1);
expect(db.query.autodraftSettings.findFirst).toHaveBeenCalledTimes(1);
expect(db.query.leagues.findFirst).toHaveBeenCalledTimes(1);
});
});