import { describe, it, expect, vi, beforeEach } from "vitest"; import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification } from "../discord"; const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc"; function mockFetch(status: number, body = "") { return vi.fn().mockResolvedValue({ ok: status >= 200 && status < 300, status, text: async () => body, }); } function getDescription(): string { const body = JSON.parse( (fetch as ReturnType).mock.calls[0][1].body ); return body.embeds[0].description as string; } function getPayload(): ReturnType { return JSON.parse((fetch as ReturnType).mock.calls[0][1].body); } describe("sendDiscordWebhook", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); it("POSTs JSON to the webhook URL", async () => { await sendDiscordWebhook(WEBHOOK_URL, { content: "hello" }); expect(fetch).toHaveBeenCalledWith(WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "hello" }), }); }); it("throws on non-2xx response", async () => { vi.stubGlobal("fetch", mockFetch(400, "Bad Request")); await expect(sendDiscordWebhook(WEBHOOK_URL, {})).rejects.toThrow( "Discord webhook failed: 400" ); }); }); describe("sendStandingsUpdateNotification", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); it("sends an embed with the season name as title", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }], previousStandings: new Map([["a", 125]]), }); const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); expect(body.embeds[0].title).toBe("📊 Standings Update — My League 2025"); }); it("shows point deltas as integers in standings changes", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Test League", standings: [ { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, { teamId: "b", teamName: "Beta", totalPoints: 100, rank: 2 }, ], previousStandings: new Map([ ["a", 125], ["b", 100], ]), }); const desc = getDescription(); expect(desc).toContain("Alpha"); expect(desc).toContain("+25 pts"); expect(desc).not.toContain("+25.0"); // Beta didn't change — not shown in standings changes expect(desc).not.toContain("Beta"); }); it("includes sport name and event name in header when both provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), sportName: "UEFA Champions League", eventName: "Knockout Stage", }); const desc = getDescription(); expect(desc).toContain("UEFA Champions League — Knockout Stage"); }); it("shows only event name when no sport name provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), eventName: "Quarter Finals", }); const desc = getDescription(); expect(desc).toContain("Quarter Finals"); expect(desc).not.toContain("—\n"); }); it("omits header section when neither sport name nor event name provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), }); const desc = getDescription(); expect(desc).toContain("**Standings Changes**"); expect(desc).not.toContain("**Scored Matches**"); }); it("lists scored matches above standings changes", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" }, { winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" }, ], }); const desc = getDescription(); expect(desc).toContain("**Scored Matches**"); expect(desc).toContain("• **Real Madrid (manager1)** def. Bayern Munich"); expect(desc).toContain("• **Arsenal** def. PSG (manager2)"); // Scored matches section should appear before standings expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Standings Changes")); }); it("shows username in parentheses in standings changes when provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 }, { teamId: "b", teamName: "Beta United", totalPoints: 100, rank: 2 }, ], previousStandings: new Map([ ["a", 125], ["b", 100], ]), }); const desc = getDescription(); expect(desc).toContain("1\\. Alpha FC (christhrowsrocks) — 150 pts"); // Beta didn't change points or rank, so it's not shown expect(desc).not.toContain("Beta United"); }); it("shows usernames in parentheses in match results when both are drafted", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Sporting", loserName: "Bodø/Glimt", winnerUsername: "christhrowsrocks", loserUsername: "apatel", }, ], }); const desc = getDescription(); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); }); it("omits scored matches where neither side has a username", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }], }); const desc = getDescription(); expect(desc).not.toContain("**Scored Matches**"); expect(desc).not.toContain("Real Madrid"); expect(desc).not.toContain("Bayern Munich"); }); it("omits username parenthesis only for the undrafted side in match results", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Sporting", loserName: "Bodø/Glimt", winnerUsername: "christhrowsrocks", }, ], }); const desc = getDescription(); expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt"); expect(desc).not.toContain("Bodø/Glimt ("); }); it("shows teams that changed rank even without a point change", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, { teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 }, { teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 }, ], previousStandings: new Map([ ["a", 125], ["b", 125], ["c", 100], ]), previousRanks: new Map([ ["a", 2], ["b", 1], ["c", 3], ]), }); const desc = getDescription(); // Alpha gained points and moved up expect(desc).toContain("Alpha"); expect(desc).toContain("+25 pts"); expect(desc).toContain("↑1"); // Beta didn't gain points but was displaced — rank changed expect(desc).toContain("Beta"); expect(desc).toContain("↓1"); // Gamma unchanged — not shown expect(desc).not.toContain("Gamma"); }); it("omits rank delta when no previousRanks provided", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }, ], previousStandings: new Map([["a", 125]]), }); const desc = getDescription(); expect(desc).toContain("Alpha"); expect(desc).not.toContain("↑"); expect(desc).not.toContain("↓"); }); it("escapes Discord markdown characters in team names and usernames", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Team__Underline", username: "user__name", totalPoints: 150, rank: 1 }, ], previousStandings: new Map([["a", 125]]), scoredMatches: [ { winnerName: "Winner*Bold*", loserName: "Loser~Strike~", winnerUsername: "user_one", loserUsername: "user_two", }, ], }); const desc = getDescription(); expect(desc).toContain("Team\\_\\_Underline"); expect(desc).toContain("user\\_\\_name"); expect(desc).toContain("Winner\\*Bold\\*"); expect(desc).toContain("Loser\\~Strike\\~"); expect(desc).toContain("user\\_one"); expect(desc).toContain("user\\_two"); }); it("uses Discord mention in standings for opted-in user", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", discordUserId: "111222333", totalPoints: 150, rank: 1, }, ], previousStandings: new Map([["a", 125]]), }); const desc = getDescription(); expect(desc).toContain("Alpha FC (<@111222333>)"); expect(desc).not.toContain("christhrowsrocks"); }); it("uses Discord mention in scored match for opted-in winner", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1", winnerDiscordUserId: "111222333", loserUsername: "manager2", }, ], }); const desc = getDescription(); expect(desc).toContain("Real Madrid (<@111222333>)"); expect(desc).not.toContain("manager1"); expect(desc).toContain("Bayern Munich (manager2)"); }); it("sets content and allowed_mentions when opted-in users are present", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", discordUserId: "111222333", totalPoints: 150, rank: 1, }, ], previousStandings: new Map([["a", 125]]), }); const payload = getPayload(); expect(payload.content).toBe("<@111222333>"); expect(payload.allowed_mentions).toEqual({ parse: [], users: ["111222333"] }); }); it("omits content and allowed_mentions when no users opted in", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha FC", username: "christhrowsrocks", totalPoints: 150, rank: 1 }, ], previousStandings: new Map([["a", 125]]), }); const payload = getPayload(); expect(payload.content).toBeUndefined(); expect(payload.allowed_mentions).toBeUndefined(); }); it("collects Discord IDs from both standings and scored matches", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha", username: "user1", discordUserId: "111", totalPoints: 150, rank: 1 }, ], previousStandings: new Map([["a", 125]]), scoredMatches: [ { winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "user2", winnerDiscordUserId: "222", loserUsername: "user1", loserDiscordUserId: "111", }, ], }); const payload = getPayload(); const ids: string[] = payload.allowed_mentions.users; expect(ids).toHaveLength(2); expect(ids).toContain("111"); expect(ids).toContain("222"); }); it("does not show fields on the embed", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }], previousStandings: new Map([["a", 100]]), }); const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); expect(body.embeds[0].fields).toBeUndefined(); }); it("omits standings changes section when no teams changed", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [ { teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }, { teamId: "b", teamName: "Beta", totalPoints: 75, rank: 2 }, ], previousStandings: new Map([ ["a", 100], ["b", 75], ]), previousRanks: new Map([ ["a", 1], ["b", 2], ]), }); const desc = getDescription(); expect(desc).not.toContain("**Standings Changes**"); expect(desc).not.toContain("Alpha"); expect(desc).not.toContain("Beta"); }); }); describe("sendDraftOrderNotification", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); it("uses '📋 Draft Order Manually Set' title for manual method", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [{ name: "Team Alpha", position: 1 }], }); const payload = getPayload(); expect(payload.embeds[0].title).toBe("Draft Order Manually Set — My League"); }); it("uses '🎲 Draft Order Randomized' title for randomized method", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "randomized", teams: [{ name: "Team Alpha", position: 1 }], }); const payload = getPayload(); expect(payload.embeds[0].title).toBe("Draft Order Randomized — My League"); }); it("links the embed title to the league URL", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [{ name: "Team Alpha", position: 1 }], }); const payload = getPayload(); expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc"); }); it("renders a numbered list of teams sorted by position", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [ { name: "Team Gamma", position: 3 }, { name: "Team Alpha", position: 1 }, { name: "Team Beta", position: 2 }, ], }); const payload = getPayload(); expect(payload.embeds[0].description).toBe( "1. Team Alpha\n2. Team Beta\n3. Team Gamma" ); }); it("shows username in parentheses next to team name when provided", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [ { name: "Team Alpha", position: 1, username: "chris" }, { name: "Team Beta", position: 2 }, ], }); const payload = getPayload(); expect(payload.embeds[0].description).toContain("1. Team Alpha (chris)"); expect(payload.embeds[0].description).toContain("2. Team Beta"); expect(payload.embeds[0].description).not.toContain("2. Team Beta ("); }); it("escapes Discord markdown in team names and usernames", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [ { name: "Team__Underline", position: 1, username: "user__name" }, { name: "Team*Bold*", position: 2 }, ], }); const payload = getPayload(); expect(payload.embeds[0].description).toContain("Team\\_\\_Underline"); expect(payload.embeds[0].description).toContain("user\\_\\_name"); expect(payload.embeds[0].description).toContain("Team\\*Bold\\*"); }); it("uses color 0x5865f2 and has no footer", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [{ name: "Team Alpha", position: 1 }], }); const payload = getPayload(); expect(payload.embeds[0].color).toBe(0x5865f2); expect(payload.embeds[0].footer).toBeUndefined(); }); it("does not set content or allowed_mentions", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [{ name: "Team Alpha", position: 1 }], }); const payload = getPayload(); expect(payload.content).toBeUndefined(); expect(payload.allowed_mentions).toBeUndefined(); }); it("renders bare team names with no trailing parentheses when no team has an owner", async () => { await sendDraftOrderNotification({ webhookUrl: WEBHOOK_URL, leagueName: "My League", leagueUrl: "https://brackt.com/leagues/abc", method: "manual", teams: [ { name: "Team Alpha", position: 1 }, { name: "Team Beta", position: 2 }, ], }); const payload = getPayload(); expect(payload.embeds[0].description).toBe("1. Team Alpha\n2. Team Beta"); expect(payload.embeds[0].description).not.toContain("("); }); }); describe("sendPickAnnouncementNotification", () => { const BASE = { webhookUrl: WEBHOOK_URL, draftUrl: "https://brackt.com/leagues/abc/draft/season-1", pickNumber: 5, round: 1, pickInRound: 5, pickedTeamName: "Alpha FC", participantName: "Erling Haaland", sportName: "Soccer", isDraftComplete: false, }; beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); it("sets embed title with pick and round info", async () => { await sendPickAnnouncementNotification(BASE); const payload = getPayload(); expect(payload.embeds[0].title).toBe("Pick #5 — Round 1, Pick 5"); }); it("sets embed url to the draft room link", async () => { await sendPickAnnouncementNotification(BASE); const payload = getPayload(); expect(payload.embeds[0].url).toBe(BASE.draftUrl); }); it("description contains team, participant, and sport", async () => { await sendPickAnnouncementNotification(BASE); const desc = getDescription(); expect(desc).toContain("**Alpha FC**"); expect(desc).toContain("**Erling Haaland**"); expect(desc).toContain("(Soccer)"); }); it("uses Discord blurple color and no footer", async () => { await sendPickAnnouncementNotification(BASE); const payload = getPayload(); expect(payload.embeds[0].color).toBe(0x5865f2); expect(payload.embeds[0].footer).toBeUndefined(); }); it("shows next team when provided", async () => { await sendPickAnnouncementNotification({ ...BASE, nextTeamName: "Beta United" }); const desc = getDescription(); expect(desc).toContain("On the clock: **Beta United**"); }); it("pings next owner with Discord mention when Discord ID is provided", async () => { await sendPickAnnouncementNotification({ ...BASE, nextTeamName: "Beta United", nextOwnerDiscordId: "999888777", }); const desc = getDescription(); expect(desc).toContain("<@999888777>"); const payload = getPayload(); expect(payload.content).toBe("<@999888777>"); expect(payload.allowed_mentions).toEqual({ parse: [], users: ["999888777"] }); }); it("shows team name without mention when no Discord ID", async () => { await sendPickAnnouncementNotification({ ...BASE, nextTeamName: "Beta United" }); const payload = getPayload(); expect(payload.content).toBeUndefined(); expect(payload.allowed_mentions).toBeUndefined(); }); it("shows draft complete message when isDraftComplete is true", async () => { await sendPickAnnouncementNotification({ ...BASE, isDraftComplete: true }); const desc = getDescription(); expect(desc).toContain("The draft is complete!"); expect(desc).not.toContain("On the clock"); const payload = getPayload(); expect(payload.content).toBeUndefined(); expect(payload.allowed_mentions).toBeUndefined(); }); it("escapes markdown in team and participant names", async () => { await sendPickAnnouncementNotification({ ...BASE, pickedTeamName: "Alpha_FC", participantName: "Player*Name", sportName: "E-Sports", }); const desc = getDescription(); expect(desc).toContain("Alpha\\_FC"); expect(desc).toContain("Player\\*Name"); }); });