import { describe, it, expect, vi, beforeEach } from "vitest"; import { sendDiscordWebhook, sendStandingsUpdateNotification } 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; } 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("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"); }); });