brackt/app/services/__tests__/discord.test.ts
Chris Parsons d271cc6792
Refactor standings notifications to show only changed teams (#182)
* 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>
2026-03-19 15:52:57 -07:00

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");
});
});