2026-03-17 11:16:36 -07:00
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
2026-07-01 17:29:45 +00:00
|
|
|
|
import { sendDiscordWebhook, sendStandingsUpdateNotification, sendDraftOrderNotification, sendPickAnnouncementNotification, sendQualifyingPointsUpdateNotification } from "../discord";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 10:06:54 -07:00
|
|
|
|
function getPayload(): ReturnType<typeof JSON.parse> {
|
|
|
|
|
|
return JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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 }],
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousStandings: new Map([["a", 125]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
it("shows point deltas as integers in standings changes", async () => {
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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");
|
2026-03-19 15:52:57 -07:00
|
|
|
|
// Beta didn't change — not shown in standings changes
|
|
|
|
|
|
expect(desc).not.toContain("Beta");
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("includes sport name and event name in header when both provided", async () => {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-03-19 15:52:57 -07:00
|
|
|
|
expect(desc).toContain("**Standings Changes**");
|
2026-03-17 11:16:36 -07:00
|
|
|
|
expect(desc).not.toContain("**Scored Matches**");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
it("lists scored matches above standings changes", async () => {
|
2026-03-17 11:16:36 -07:00
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
scoredMatches: [
|
2026-03-19 15:52:57 -07:00
|
|
|
|
{ winnerName: "Real Madrid", loserName: "Bayern Munich", winnerUsername: "manager1" },
|
|
|
|
|
|
{ winnerName: "Arsenal", loserName: "PSG", loserUsername: "manager2" },
|
2026-03-17 11:16:36 -07:00
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
expect(desc).toContain("**Scored Matches**");
|
2026-03-19 15:52:57 -07:00
|
|
|
|
expect(desc).toContain("• **Real Madrid (manager1)** def. Bayern Munich");
|
|
|
|
|
|
expect(desc).toContain("• **Arsenal** def. PSG (manager2)");
|
2026-03-17 11:16:36 -07:00
|
|
|
|
// Scored matches section should appear before standings
|
2026-03-19 15:52:57 -07:00
|
|
|
|
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Standings Changes"));
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
it("shows username in parentheses in standings changes when provided", async () => {
|
2026-03-17 14:34:09 -07:00
|
|
|
|
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 },
|
|
|
|
|
|
],
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousStandings: new Map([
|
|
|
|
|
|
["a", 125],
|
|
|
|
|
|
["b", 100],
|
|
|
|
|
|
]),
|
2026-03-17 14:34:09 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-03-27 20:45:15 -07:00
|
|
|
|
expect(desc).toContain("1\\. Alpha FC (christhrowsrocks) — 150 pts");
|
2026-03-19 15:52:57 -07:00
|
|
|
|
// Beta didn't change points or rank, so it's not shown
|
|
|
|
|
|
expect(desc).not.toContain("Beta United");
|
2026-03-17 14:34:09 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("shows usernames in parentheses in match results when both are drafted", async () => {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 14:34:09 -07:00
|
|
|
|
scoredMatches: [
|
|
|
|
|
|
{
|
|
|
|
|
|
winnerName: "Sporting",
|
|
|
|
|
|
loserName: "Bodø/Glimt",
|
|
|
|
|
|
winnerUsername: "christhrowsrocks",
|
|
|
|
|
|
loserUsername: "apatel",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-30 23:48:11 +00:00
|
|
|
|
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.
|
2026-06-30 01:58:20 +00:00
|
|
|
|
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",
|
2026-06-30 23:48:11 +00:00
|
|
|
|
winnerUsername: "aliceManager",
|
2026-06-30 01:58:20 +00:00
|
|
|
|
loserUsername: "ikyn",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-06-30 23:48:11 +00:00
|
|
|
|
expect(desc).toContain("• **Brazil (aliceManager)** def. Japan (ikyn)");
|
2026-06-30 01:58:20 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
it("omits scored matches where neither side has a username", async () => {
|
2026-03-17 14:34:09 -07:00
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 14:34:09 -07:00
|
|
|
|
scoredMatches: [{ winnerName: "Real Madrid", loserName: "Bayern Munich" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-03-19 15:52:57 -07:00
|
|
|
|
expect(desc).not.toContain("**Scored Matches**");
|
|
|
|
|
|
expect(desc).not.toContain("Real Madrid");
|
|
|
|
|
|
expect(desc).not.toContain("Bayern Munich");
|
2026-03-17 14:34:09 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("omits username parenthesis only for the undrafted side in match results", async () => {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 14:34:09 -07:00
|
|
|
|
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 (");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
it("shows teams that changed rank even without a point change", async () => {
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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 },
|
|
|
|
|
|
],
|
2026-03-19 15:52:57 -07:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-04 22:02:06 -07:00
|
|
|
|
it("pings scorers but not rank-only shufflers", async () => {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
|
|
|
|
|
standings: [
|
|
|
|
|
|
// Alpha scored — points changed, moved up.
|
|
|
|
|
|
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1, discordUserId: "111" },
|
|
|
|
|
|
// Beta was displaced down without scoring — rank changed only.
|
|
|
|
|
|
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2, username: "beta_owner", discordUserId: "222" },
|
|
|
|
|
|
],
|
|
|
|
|
|
previousStandings: new Map([
|
|
|
|
|
|
["a", 125],
|
|
|
|
|
|
["b", 125],
|
|
|
|
|
|
]),
|
|
|
|
|
|
previousRanks: new Map([
|
|
|
|
|
|
["a", 2],
|
|
|
|
|
|
["b", 1],
|
|
|
|
|
|
]),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
// Beta is still displayed with its rank movement…
|
|
|
|
|
|
expect(desc).toContain("Beta");
|
|
|
|
|
|
expect(desc).toContain("↓1");
|
|
|
|
|
|
// …but by name, not as an @-mention.
|
|
|
|
|
|
expect(desc).not.toContain("<@222>");
|
|
|
|
|
|
// Alpha scored, so it keeps its mention.
|
|
|
|
|
|
expect(desc).toContain("<@111>");
|
|
|
|
|
|
|
|
|
|
|
|
const payload = getPayload();
|
|
|
|
|
|
// Only the scorer is pinged.
|
|
|
|
|
|
expect(payload.content).toContain("111");
|
|
|
|
|
|
expect(payload.content).not.toContain("222");
|
|
|
|
|
|
expect(payload.allowed_mentions.users).toContain("111");
|
|
|
|
|
|
expect(payload.allowed_mentions.users).not.toContain("222");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-03-19 15:52:57 -07:00
|
|
|
|
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");
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-15 10:06:54 -07:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
it("does not show fields on the embed", async () => {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "My League 2025",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 125, rank: 1 }],
|
|
|
|
|
|
previousStandings: new Map([["a", 100]]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
|
|
|
|
expect(body.embeds[0].fields).toBeUndefined();
|
|
|
|
|
|
});
|
2026-03-19 15:52:57 -07:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
2026-06-28 19:36:33 +00:00
|
|
|
|
|
|
|
|
|
|
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**");
|
|
|
|
|
|
});
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
2026-05-17 23:20:51 -07:00
|
|
|
|
|
|
|
|
|
|
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("(");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-20 19:55:48 -07:00
|
|
|
|
|
2026-07-03 21:40:11 +00:00
|
|
|
|
// The caller now supplies each entry's rank in the FULL season field. This helper
|
|
|
|
|
|
// mirrors the production ranking (qualifying-points-discord.server.ts): sort by
|
|
|
|
|
|
// qpTotal desc, competition ranking with ties sharing the lower rank, so existing
|
|
|
|
|
|
// tests keep asserting ranks derived from qpTotal.
|
|
|
|
|
|
function withRanks<T extends { qpTotal: number }>(entries: T[]) {
|
|
|
|
|
|
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
|
|
|
|
|
|
const rankByTotal = new Map<number, number>();
|
|
|
|
|
|
let prevTotal = Number.NaN;
|
|
|
|
|
|
let prevRank = 0;
|
|
|
|
|
|
sorted.forEach((e, i) => {
|
|
|
|
|
|
const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1;
|
|
|
|
|
|
rankByTotal.set(e.qpTotal, rank);
|
|
|
|
|
|
prevTotal = e.qpTotal;
|
|
|
|
|
|
prevRank = rank;
|
|
|
|
|
|
});
|
|
|
|
|
|
const countByTotal = new Map<number, number>();
|
|
|
|
|
|
for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1);
|
|
|
|
|
|
return entries.map((e) => ({
|
|
|
|
|
|
...e,
|
|
|
|
|
|
globalRank: rankByTotal.get(e.qpTotal) ?? 0,
|
|
|
|
|
|
globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 17:29:45 +00:00
|
|
|
|
describe("sendQualifyingPointsUpdateNotification", () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.stubGlobal("fetch", mockFetch(204));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 21:40:11 +00:00
|
|
|
|
const BASE_ENTRIES = withRanks([
|
2026-07-01 17:29:45 +00:00
|
|
|
|
{ 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" },
|
2026-07-03 21:40:11 +00:00
|
|
|
|
]);
|
2026-07-01 17:29:45 +00:00
|
|
|
|
|
2026-07-09 05:12:40 +00:00
|
|
|
|
it("sends an embed with gold color, QP title, no footer, and links the title to the standings page", async () => {
|
2026-07-01 17:29:45 +00:00
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
|
|
|
|
|
entries: BASE_ENTRIES,
|
2026-07-09 05:12:40 +00:00
|
|
|
|
standingsUrl: "https://brackt.com/leagues/abc/sports-seasons/ss-1",
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const payload = getPayload();
|
|
|
|
|
|
expect(payload.embeds[0].title).toBe("🏅 Qualifying Points Update — Slam League 2025");
|
|
|
|
|
|
expect(payload.embeds[0].color).toBe(0x5865f2);
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(payload.embeds[0].url).toBe("https://brackt.com/leagues/abc/sports-seasons/ss-1");
|
|
|
|
|
|
expect(payload.embeds[0].footer).toBeUndefined();
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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**");
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).toContain("• **Novak Djokovic (alex)** — 20 QP");
|
|
|
|
|
|
expect(desc).toContain("• **Carlos Alcaraz (chris)** — 14 QP");
|
2026-07-01 17:29:45 +00:00
|
|
|
|
// Djokovic (20 QP) should appear before Alcaraz (14 QP)
|
|
|
|
|
|
expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 10:04:22 -07:00
|
|
|
|
it("lists non-scoring participants (outside top 8) as one comma-separated line below Top 8", async () => {
|
|
|
|
|
|
// Non-scoring now draws from the full scoreboard: every drafted participant NOT in
|
|
|
|
|
|
// the top 8 (rank 9+ or unranked), rendered on a single line "Name (total, manager)"
|
|
|
|
|
|
// ordered by season total desc — not just this event's zero-earners.
|
2026-07-01 17:29:45 +00:00
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
2026-07-09 10:04:22 -07:00
|
|
|
|
entries: [
|
|
|
|
|
|
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
|
|
|
|
|
],
|
|
|
|
|
|
scoreboard: [
|
|
|
|
|
|
{ participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
|
|
|
|
|
{ participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" },
|
|
|
|
|
|
{ participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" },
|
|
|
|
|
|
],
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).toContain("**Non-scoring Participants**");
|
2026-07-09 10:04:22 -07:00
|
|
|
|
// Single comma-separated line: "Name (seasonTotal, manager)", higher total first.
|
|
|
|
|
|
expect(desc).toContain("Also Ran (5, chris), Winless Wonder (0, sam)");
|
|
|
|
|
|
// Champ is in the top 8, so it must NOT appear in the non-scoring line.
|
|
|
|
|
|
expect(desc).not.toContain("Champ (100");
|
|
|
|
|
|
// Non-scoring appears below the Top 8 section.
|
|
|
|
|
|
expect(desc.indexOf("Drafted Participants in Top 8")).toBeLessThan(
|
|
|
|
|
|
desc.indexOf("Non-scoring Participants")
|
|
|
|
|
|
);
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 10:04:22 -07:00
|
|
|
|
it("keeps zero-QP participants tied into the top-8 rank band out of the Top 8 section", async () => {
|
|
|
|
|
|
// Regression: early in a season, standard competition ranking ties every winless
|
|
|
|
|
|
// player into a low rank (here rank 2). A rank-only Top 8 filter would flood the
|
|
|
|
|
|
// section with "T2. Name — 0 QP" rows; the qpTotal>0 guard drops them to Non-scoring.
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
|
|
|
|
|
entries: [
|
|
|
|
|
|
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
|
|
|
|
|
],
|
|
|
|
|
|
scoreboard: [
|
|
|
|
|
|
{ participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" },
|
|
|
|
|
|
{ participantName: "Winless A", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "chris" },
|
|
|
|
|
|
{ participantName: "Winless B", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "sam" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
// Top 8 contains only the real scorer.
|
|
|
|
|
|
expect(desc).toContain("1\\. Only Scorer (alex) — 10 QP");
|
|
|
|
|
|
// The tied 0-QP players must NOT appear as top-8 rows.
|
|
|
|
|
|
expect(desc).not.toContain("T2\\. Winless A");
|
|
|
|
|
|
expect(desc).not.toContain("T2\\. Winless B");
|
|
|
|
|
|
// They land in the Non-scoring line instead.
|
|
|
|
|
|
expect(desc).toContain("Winless A (0, chris), Winless B (0, sam)");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("shows Drafted Participants in Top 8 for scoreboard sorted by qpTotal desc", async () => {
|
2026-07-01 17:29:45 +00:00
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
|
|
|
|
|
entries: BASE_ENTRIES,
|
2026-07-09 10:04:22 -07:00
|
|
|
|
scoreboard: BASE_ENTRIES,
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).toContain("**Drafted Participants in Top 8**");
|
2026-07-01 17:29:45 +00:00
|
|
|
|
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",
|
2026-07-03 21:40:11 +00:00
|
|
|
|
entries: withRanks([
|
2026-07-01 17:29:45 +00:00
|
|
|
|
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
|
|
|
|
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
|
|
|
|
|
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
2026-07-03 21:40:11 +00:00
|
|
|
|
]),
|
2026-07-09 10:04:22 -07:00
|
|
|
|
scoreboard: withRanks([
|
|
|
|
|
|
{ participantName: "Player A", qpEarned: 10, qpTotal: 25 },
|
|
|
|
|
|
{ participantName: "Player B", qpEarned: 5, qpTotal: 25 },
|
|
|
|
|
|
{ participantName: "Player C", qpEarned: 2, qpTotal: 10 },
|
|
|
|
|
|
]),
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
expect(desc).toContain("T1\\. Player A");
|
|
|
|
|
|
expect(desc).toContain("T1\\. Player B");
|
|
|
|
|
|
expect(desc).toContain("3\\. Player C");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 21:40:11 +00:00
|
|
|
|
it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => {
|
|
|
|
|
|
// Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP
|
|
|
|
|
|
// (positions 9–16 split) but Discord rounded it to 2 via Math.round.
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Rumble League 2026",
|
|
|
|
|
|
sportName: "Tennis - Men",
|
|
|
|
|
|
eventName: "Wimbledon",
|
|
|
|
|
|
entries: [
|
|
|
|
|
|
{
|
|
|
|
|
|
participantName: "Novak Djokovic",
|
|
|
|
|
|
qpEarned: 1.5,
|
|
|
|
|
|
qpTotal: 1.5,
|
|
|
|
|
|
globalRank: 9,
|
|
|
|
|
|
globalRankTied: true,
|
|
|
|
|
|
ownerUsername: "snarkymcgee",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-07-09 06:34:25 +00:00
|
|
|
|
// Mirrors the web UI's formatQP: fractional QP renders with up to 2 decimals and
|
|
|
|
|
|
// trailing zeros trimmed ("1.5"), never rounded to an integer. The Points Awarded
|
|
|
|
|
|
// line no longer carries a "+".
|
|
|
|
|
|
expect(desc).toContain("• **Novak Djokovic (snarkymcgee)** — 1.5 QP");
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).not.toContain("+");
|
|
|
|
|
|
expect(desc).not.toContain("2 QP");
|
2026-07-09 06:34:25 +00:00
|
|
|
|
expect(desc).not.toContain("1.50");
|
2026-07-03 21:40:11 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 05:12:40 +00:00
|
|
|
|
it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => {
|
|
|
|
|
|
// Only the top 8 (plus ties) of the full season field belong in this section, so a
|
|
|
|
|
|
// rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded.
|
2026-07-09 10:04:22 -07:00
|
|
|
|
const both = [
|
|
|
|
|
|
{
|
|
|
|
|
|
participantName: "Player Eight",
|
|
|
|
|
|
qpEarned: 5,
|
|
|
|
|
|
qpTotal: 10,
|
|
|
|
|
|
globalRank: 8,
|
|
|
|
|
|
globalRankTied: false,
|
|
|
|
|
|
ownerUsername: "eighthowner",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
participantName: "Player Nine",
|
|
|
|
|
|
qpEarned: 3,
|
|
|
|
|
|
qpTotal: 8,
|
|
|
|
|
|
globalRank: 9,
|
|
|
|
|
|
globalRankTied: false,
|
|
|
|
|
|
ownerUsername: "ninthowner",
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-07-03 21:40:11 +00:00
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Rumble League 2026",
|
|
|
|
|
|
sportName: "Tennis - Men",
|
|
|
|
|
|
eventName: "Wimbledon",
|
2026-07-09 10:04:22 -07:00
|
|
|
|
entries: both,
|
|
|
|
|
|
scoreboard: both,
|
2026-07-03 21:40:11 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).toContain("**Drafted Participants in Top 8**");
|
|
|
|
|
|
expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP");
|
|
|
|
|
|
// The rank-9 player is filtered out of the standings section entirely.
|
|
|
|
|
|
expect(desc).not.toContain("9\\. Player Nine");
|
2026-07-09 10:04:22 -07:00
|
|
|
|
// ...instead the rank-9 player drops into the Non-scoring line.
|
|
|
|
|
|
expect(desc).toContain("Player Nine (8, ninthowner)");
|
|
|
|
|
|
// ...and both still earned points, so both remain in Points Awarded.
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP");
|
2026-07-03 21:40:11 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-01 17:29:45 +00:00
|
|
|
|
it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => {
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
2026-07-03 21:40:11 +00:00
|
|
|
|
entries: withRanks([
|
2026-07-01 17:29:45 +00:00
|
|
|
|
{
|
|
|
|
|
|
participantName: "Novak Djokovic",
|
|
|
|
|
|
qpEarned: 20,
|
|
|
|
|
|
qpTotal: 45,
|
|
|
|
|
|
ownerUsername: "alex",
|
|
|
|
|
|
ownerDiscordUserId: "111222333",
|
|
|
|
|
|
},
|
2026-07-03 21:40:11 +00:00
|
|
|
|
]),
|
2026-07-09 10:04:22 -07:00
|
|
|
|
scoreboard: withRanks([
|
|
|
|
|
|
{
|
|
|
|
|
|
participantName: "Novak Djokovic",
|
|
|
|
|
|
qpEarned: 20,
|
|
|
|
|
|
qpTotal: 45,
|
|
|
|
|
|
ownerUsername: "alex",
|
|
|
|
|
|
},
|
|
|
|
|
|
]),
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
2026-07-09 05:12:40 +00:00
|
|
|
|
// Points Awarded (a pinged section) uses the Discord mention...
|
|
|
|
|
|
expect(desc).toContain("• **Novak Djokovic (<@111222333>)** — 20 QP");
|
|
|
|
|
|
// ...while the (non-pinged) Top 8 standings section uses the plain username.
|
|
|
|
|
|
expect(desc).toContain("1\\. Novak Djokovic (alex) — 45 QP");
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 05:12:40 +00:00
|
|
|
|
it("pings awarded owners but not non-scoring owners", async () => {
|
2026-07-01 17:29:45 +00:00
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
2026-07-03 21:40:11 +00:00
|
|
|
|
entries: withRanks([
|
2026-07-01 17:29:45 +00:00
|
|
|
|
{ participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" },
|
|
|
|
|
|
{ participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" },
|
2026-07-03 21:40:11 +00:00
|
|
|
|
]),
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const payload = getPayload();
|
|
|
|
|
|
expect(payload.content).toContain("<@111>");
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(payload.content).not.toContain("<@222>");
|
2026-07-01 17:29:45 +00:00
|
|
|
|
expect(payload.allowed_mentions.users).toContain("111");
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(payload.allowed_mentions.users).not.toContain("222");
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not send when entries array is empty", async () => {
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
|
|
|
|
|
entries: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
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)");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 05:12:40 +00:00
|
|
|
|
it("sends with only a Knocked Out section when there are no QP entries, omitting the standings section", async () => {
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
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**");
|
2026-07-09 05:12:40 +00:00
|
|
|
|
expect(desc).not.toContain("**Drafted Participants in Top 8**");
|
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-01 17:29:45 +00:00
|
|
|
|
it("escapes markdown in participant names and usernames", async () => {
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
2026-07-03 21:40:11 +00:00
|
|
|
|
entries: withRanks([
|
2026-07-01 17:29:45 +00:00
|
|
|
|
{ participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" },
|
2026-07-03 21:40:11 +00:00
|
|
|
|
]),
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
expect(desc).toContain("Player\\_One");
|
|
|
|
|
|
expect(desc).toContain("user\\_name");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("truncates description at 4096 characters", async () => {
|
2026-07-03 21:40:11 +00:00
|
|
|
|
const longEntries = withRanks(
|
|
|
|
|
|
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}`,
|
|
|
|
|
|
}))
|
|
|
|
|
|
);
|
2026-07-01 17:29:45 +00:00
|
|
|
|
|
|
|
|
|
|
await sendQualifyingPointsUpdateNotification({
|
|
|
|
|
|
webhookUrl: WEBHOOK_URL,
|
|
|
|
|
|
seasonName: "Slam League 2025",
|
|
|
|
|
|
entries: longEntries,
|
2026-07-09 10:04:22 -07:00
|
|
|
|
scoreboard: longEntries,
|
2026-07-01 17:29:45 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const desc = getDescription();
|
|
|
|
|
|
expect(desc.length).toBeLessThanOrEqual(4096);
|
|
|
|
|
|
expect(desc.endsWith("...")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 19:55:48 -07:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|