diff --git a/app/components/league/settings/NotificationsSection.tsx b/app/components/league/settings/NotificationsSection.tsx index b9fc12f..52b8100 100644 --- a/app/components/league/settings/NotificationsSection.tsx +++ b/app/components/league/settings/NotificationsSection.tsx @@ -43,7 +43,7 @@ export function NotificationsSection({ Create a webhook in Discord under Server Settings, Integrations, Webhooks.
- {savedDiscordWebhookUrl && ( + {savedDiscordWebhookUrl ? (+ Save the webhook URL above to configure additional notification options. +
+ ) : null} ); diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 81f370e..0dfb4fc 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -842,23 +842,22 @@ export async function executeAutoPick(params: { .catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err)); } - { - const pickedSlot = draftSlots.find((s) => s.teamId === teamId); - let nextDraftSlotTeamId: string | null = null; - if (!isDraftComplete) { - const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams); - nextDraftSlotTeamId = draftSlots.find((s) => s.draftOrder === nextPickInRound)?.teamId ?? null; - } + const pickedSlot = draftSlots.find((s) => s.teamId === teamId); + if (!pickedSlot) { + logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`); + } else { notifyPickMadeOnDiscord({ seasonId, - pickedTeamName: pickedSlot?.team.name ?? "", + leagueId: season.leagueId, + pickedTeamName: pickedSlot.team.name, participantName: participantToPick.name, sportName: participantToPick.sportsSeason.sport.name, pickNumber, round: currentRound, pickInRound, isDraftComplete, - nextDraftSlotTeamId, + totalTeams, + draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), db, }).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)); } diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index ef7f3cc..f1eb752 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -331,25 +331,20 @@ export async function action(args: ActionFunctionArgs) { .catch((err) => logger.error("On-the-clock email failed:", err)); } - { - let nextDraftSlotTeamId: string | null = null; - if (!isDraftComplete) { - const { pickInRound: nextPickInRound } = calculatePickInfo(nextPickNumber, totalTeams); - nextDraftSlotTeamId = draftSlots.find((s) => s.draftOrder === nextPickInRound)?.teamId ?? null; - } - notifyPickMadeOnDiscord({ - seasonId, - pickedTeamName: currentDraftSlot.team.name, - participantName: participant.name, - sportName: participant.sportsSeason.sport.name, - pickNumber: currentPickNumber, - round: currentRound, - pickInRound, - isDraftComplete, - nextDraftSlotTeamId, - db, - }).catch((err) => logger.error("Discord pick announcement failed:", err)); - } + notifyPickMadeOnDiscord({ + seasonId, + leagueId: season.leagueId, + pickedTeamName: currentDraftSlot.team.name, + participantName: participant.name, + sportName: participant.sportsSeason.sport.name, + pickNumber: currentPickNumber, + round: currentRound, + pickInRound, + isDraftComplete, + totalTeams, + draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), + db, + }).catch((err) => logger.error("Discord pick announcement failed:", err)); return Response.json({ success: true, diff --git a/app/services/__tests__/draft-discord.server.test.ts b/app/services/__tests__/draft-discord.server.test.ts new file mode 100644 index 0000000..00b9d2b --- /dev/null +++ b/app/services/__tests__/draft-discord.server.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +vi.mock("~/services/discord", () => ({ + sendPickAnnouncementNotification: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("~/models/account", () => ({ + findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()), +})); + +import { notifyPickMadeOnDiscord } from "../draft-discord.server"; +import { sendPickAnnouncementNotification } from "~/services/discord"; +import { findDiscordIdsByUserIds } from "~/models/account"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SEASON_ID = "season-1"; +const LEAGUE_ID = "league-1"; +const TEAM_ID = "team-1"; +const NEXT_TEAM_ID = "team-2"; +const OWNER_ID = "owner-1"; +const NEXT_OWNER_ID = "owner-2"; +const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc"; + +/** 2-team draft; snake: pick 1→team1, pick 2→team2, pick 3→team2, pick 4→team1 */ +const DRAFT_SLOTS = [ + { teamId: TEAM_ID, draftOrder: 1 }, + { teamId: NEXT_TEAM_ID, draftOrder: 2 }, +]; + +const BASE_PARAMS = { + seasonId: SEASON_ID, + leagueId: LEAGUE_ID, + pickedTeamName: "Alpha FC", + participantName: "Erling Haaland", + sportName: "Soccer", + pickNumber: 1, + round: 1, + pickInRound: 1, + isDraftComplete: false, + totalTeams: 2, + draftSlots: DRAFT_SLOTS, +}; + +function makeLeague(overrides: object = {}) { + return { + id: LEAGUE_ID, + discordWebhookUrl: WEBHOOK_URL, + discordPicksAnnouncementEnabled: true, + ...overrides, + }; +} + +function makeSeason(currentPickNumber = 2) { + return { id: SEASON_ID, leagueId: LEAGUE_ID, currentPickNumber }; +} + +function makeTeam(id: string, ownerId: string | null = null, name = "Beta United") { + return { id, name, ownerId }; +} + +function makeOwner(id: string, discordPingEnabled = true) { + return { id, discordPingEnabled }; +} + +function makeMockDb(overrides: { + league?: object | null; + season?: object | null; + nextTeam?: object | null; + owner?: object | null; +} = {}) { + const league = "league" in overrides ? overrides.league : makeLeague(); + const season = "season" in overrides ? overrides.season : makeSeason(); + const nextTeam = "nextTeam" in overrides ? overrides.nextTeam : makeTeam(NEXT_TEAM_ID, NEXT_OWNER_ID); + const owner = "owner" in overrides ? overrides.owner : makeOwner(NEXT_OWNER_ID); + + return { + query: { + leagues: { findFirst: vi.fn().mockResolvedValue(league) }, + seasons: { findFirst: vi.fn().mockResolvedValue(season) }, + teams: { findFirst: vi.fn().mockResolvedValue(nextTeam) }, + users: { findFirst: vi.fn().mockResolvedValue(owner) }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + process.env.APP_URL = "https://test.brackt.com"; +}); + +afterEach(() => { + delete process.env.APP_URL; +}); + +describe("notifyPickMadeOnDiscord", () => { + it("returns early when league has no webhook URL", async () => { + const db = makeMockDb({ league: makeLeague({ discordWebhookUrl: null }) }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).not.toHaveBeenCalled(); + }); + + it("returns early when picks announcement toggle is disabled", async () => { + const db = makeMockDb({ league: makeLeague({ discordPicksAnnouncementEnabled: false }) }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).not.toHaveBeenCalled(); + }); + + it("returns early when league is not found", async () => { + const db = makeMockDb({ league: null }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).not.toHaveBeenCalled(); + }); + + it("sends announcement with correct pick details", async () => { + const db = makeMockDb(); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ + webhookUrl: WEBHOOK_URL, + pickedTeamName: "Alpha FC", + participantName: "Erling Haaland", + sportName: "Soccer", + pickNumber: 1, + round: 1, + pickInRound: 1, + isDraftComplete: false, + }) + ); + }); + + it("builds the draft URL from APP_URL env var", async () => { + const db = makeMockDb(); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ + draftUrl: `https://test.brackt.com/leagues/${LEAGUE_ID}/draft/${SEASON_ID}`, + }) + ); + }); + + it("passes nextTeamName when fresh season resolves the next slot", async () => { + const db = makeMockDb({ season: makeSeason(2) }); // currentPickNumber=2 → pickInRound=2 → NEXT_TEAM_ID + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ nextTeamName: "Beta United" }) + ); + }); + + it("reads next team from fresh DB pick number, not caller-supplied snapshot", async () => { + // Simulate autodraft chain advancing 2 picks: currentPickNumber=3 on a 2-team snake + // draft means pickInRound=2 again (round 2 reversed) → NEXT_TEAM_ID + const db = makeMockDb({ season: makeSeason(3) }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(db.query.seasons.findFirst).toHaveBeenCalledTimes(1); + }); + + it("passes nextOwnerDiscordId when owner has discordPingEnabled", async () => { + vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[NEXT_OWNER_ID, "discord-999"]])); + const db = makeMockDb(); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ nextOwnerDiscordId: "discord-999" }) + ); + }); + + it("omits nextOwnerDiscordId when owner has discordPingEnabled: false", async () => { + const db = makeMockDb({ owner: makeOwner(NEXT_OWNER_ID, false) }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(findDiscordIdsByUserIds).not.toHaveBeenCalled(); + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ nextOwnerDiscordId: undefined }) + ); + }); + + it("omits nextTeamName when next team has no owner", async () => { + const db = makeMockDb({ nextTeam: makeTeam(NEXT_TEAM_ID, null) }); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }); + + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ nextTeamName: "Beta United", nextOwnerDiscordId: undefined }) + ); + }); + + it("skips next-team lookup and passes isDraftComplete: true when draft is done", async () => { + const db = makeMockDb(); + + await notifyPickMadeOnDiscord({ ...BASE_PARAMS, isDraftComplete: true, db: db as never }); + + expect(db.query.seasons.findFirst).not.toHaveBeenCalled(); + expect(sendPickAnnouncementNotification).toHaveBeenCalledWith( + expect.objectContaining({ isDraftComplete: true, nextTeamName: undefined }) + ); + }); + + it("suppresses errors thrown by sendPickAnnouncementNotification — caller's .catch handles them", async () => { + vi.mocked(sendPickAnnouncementNotification).mockRejectedValue(new Error("webhook down")); + const db = makeMockDb(); + + // The function itself does NOT swallow errors; the caller chains .catch(). + await expect( + notifyPickMadeOnDiscord({ ...BASE_PARAMS, db: db as never }) + ).rejects.toThrow("webhook down"); + }); +}); diff --git a/app/services/discord.ts b/app/services/discord.ts index 3b7de68..0951e67 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -217,7 +217,6 @@ export async function sendPickAnnouncementNotification({ }): Promise