brackt/app/services/__tests__/draft-email.server.test.ts

248 lines
8 KiB
TypeScript
Raw Normal View History

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
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);
});
});