import { describe, it, expect, vi, beforeEach } from "vitest"; import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } 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("shows winner's manager for context when the match fires due to an owned loser", async () => { // Brazil beats Japan (R32, non-scoring). Japan's manager is the reason for the // notification; Brazil's manager is shown for context even though they didn't score. // scoring-calculator.ts controls whether to include the match; discord.ts just renders. await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Rumble League 2026", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], previousStandings: new Map([["a", 100]]), scoredMatches: [ { winnerName: "Brazil", loserName: "Japan", winnerUsername: "aliceManager", loserUsername: "ikyn", }, ], }); const desc = getDescription(); expect(desc).toContain("β€’ **Brazil (aliceManager)** def. Japan (ikyn)"); }); 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"); }); it("lists eliminated teams with their managers", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], previousStandings: new Map([["a", 100]]), eliminatedTeams: [ { participantName: "Phoenix Suns", username: "manager1" }, { participantName: "Toronto Raptors" }, ], }); const desc = getDescription(); expect(desc).toContain("**Eliminated**"); expect(desc).toContain("β€’ **Phoenix Suns (manager1)**"); expect(desc).toContain("β€’ **Toronto Raptors**"); }); it("uses Discord mention for opted-in eliminated managers and pings them", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], previousStandings: new Map([["a", 100]]), eliminatedTeams: [ { participantName: "Phoenix Suns", username: "manager1", discordUserId: "555" }, ], }); const desc = getDescription(); expect(desc).toContain("β€’ **Phoenix Suns (<@555>)**"); expect(desc).not.toContain("manager1"); const payload = getPayload(); expect(payload.content).toBe("<@555>"); expect(payload.allowed_mentions).toEqual({ parse: [], users: ["555"] }); }); it("escapes Discord markdown in eliminated team names", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "My League 2025", standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], previousStandings: new Map([["a", 100]]), eliminatedTeams: [{ participantName: "Team__Bold", username: "user_name" }], }); const desc = getDescription(); expect(desc).toContain("Team\\_\\_Bold"); expect(desc).toContain("user\\_name"); }); it("omits the eliminated section when none 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).not.toContain("**Eliminated**"); }); }); 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("sendQualifyingPointsUpdateNotification", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); const BASE_ENTRIES = [ { participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" }, { participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" }, { participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" }, ]; it("sends an embed with gold color and QP title", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, }); const payload = getPayload(); expect(payload.embeds[0].title).toBe("πŸ… Qualifying Points Update β€” Slam League 2025"); expect(payload.embeds[0].color).toBe(0x5865f2); expect(payload.embeds[0].footer).toEqual({ text: "brackt.com" }); }); it("shows sport and event header", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", sportName: "ATP Tennis", eventName: "Wimbledon 2025", entries: BASE_ENTRIES, }); const desc = getDescription(); expect(desc).toContain("**ATP Tennis β€” Wimbledon 2025**"); }); it("shows Points Awarded section for entries with qpEarned > 0, sorted by QP desc", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, }); const desc = getDescription(); expect(desc).toContain("**Points Awarded**"); expect(desc).toContain("β€’ **Novak Djokovic (alex)** β€” +20 QP"); expect(desc).toContain("β€’ **Carlos Alcaraz (chris)** β€” +14 QP"); // Djokovic (20 QP) should appear before Alcaraz (14 QP) expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz")); }); it("shows No Points section for entries with qpEarned == 0", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, }); const desc = getDescription(); expect(desc).toContain("**No Points**"); expect(desc).toContain("Rafael Nadal (alex)"); }); it("shows QP Standings for all entries sorted by qpTotal desc", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, }); const desc = getDescription(); expect(desc).toContain("**QP Standings**"); expect(desc).toContain("1\\. Novak Djokovic (alex) β€” 45 QP"); expect(desc).toContain("2\\. Carlos Alcaraz (chris) β€” 34 QP"); expect(desc).toContain("3\\. Rafael Nadal (alex) β€” 20 QP"); // Djokovic should rank above Alcaraz expect(desc.indexOf("1\\. Novak")).toBeLessThan(desc.indexOf("2\\. Carlos")); }); it("uses T-prefix for tied QP totals in standings", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [ { participantName: "Player A", qpEarned: 10, qpTotal: 25 }, { participantName: "Player B", qpEarned: 5, qpTotal: 25 }, { participantName: "Player C", qpEarned: 2, qpTotal: 10 }, ], }); const desc = getDescription(); expect(desc).toContain("T1\\. Player A"); expect(desc).toContain("T1\\. Player B"); expect(desc).toContain("3\\. Player C"); }); it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [ { participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex", ownerDiscordUserId: "111222333", }, ], }); const desc = getDescription(); expect(desc).toContain("Novak Djokovic (<@111222333>)"); expect(desc).not.toContain("(alex)"); }); it("pings opted-in owners who appear in awarded or no-points sections", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [ { participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" }, { participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" }, ], }); const payload = getPayload(); expect(payload.content).toContain("<@111>"); expect(payload.content).toContain("<@222>"); expect(payload.allowed_mentions.users).toContain("111"); expect(payload.allowed_mentions.users).toContain("222"); }); it("does not send when entries array is empty", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [], }); expect(fetch).not.toHaveBeenCalled(); }); it("shows Knocked Out section for eliminated entries, tagging the manager", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }], }); const desc = getDescription(); expect(desc).toContain("**Knocked Out**"); expect(desc).toContain("β€’ Jakob Mensik (chris)"); }); it("sends with only a Knocked Out section when there are no QP entries, omitting QP Standings", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [], eliminated: [{ participantName: "Jakob Mensik", ownerUsername: "chris" }], }); expect(fetch).toHaveBeenCalledOnce(); const desc = getDescription(); expect(desc).toContain("**Knocked Out**"); expect(desc).not.toContain("**QP Standings**"); expect(desc).not.toContain("**Points Awarded**"); }); it("pings opted-in owners who appear only in the Knocked Out section", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [], eliminated: [{ participantName: "Jakob Mensik", ownerDiscordUserId: "777" }], }); const payload = getPayload(); expect(payload.content).toContain("<@777>"); expect(payload.allowed_mentions.users).toContain("777"); }); it("escapes markdown in participant names and usernames", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: [ { participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" }, ], }); const desc = getDescription(); expect(desc).toContain("Player\\_One"); expect(desc).toContain("user\\_name"); }); it("truncates description at 4096 characters", async () => { const longEntries = Array.from({ length: 200 }, (_, i) => ({ participantName: `Very Long Participant Name Number ${i}`, qpEarned: i % 2 === 0 ? 5 : 0, qpTotal: 200 - i, ownerUsername: `owner_with_long_username_${i}`, })); await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: longEntries, }); const desc = getDescription(); expect(desc.length).toBeLessThanOrEqual(4096); expect(desc.endsWith("...")).toBe(true); }); }); 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"); }); });