* Refine Discord webhook scoring notifications (fixes #180, #181) - Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username — prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude <noreply@anthropic.com>
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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");
|
|
});
|
|
});
|