brackt/app/services/__tests__/qualifying-points-discord.server.test.ts
Chris Parsons 5d0363a309
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m8s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Fix qualifying-points notifications: full-field scoreboard + manual-set announcements
The QP Discord embed's two "scoreboard" sections now reflect the whole drafted
field instead of only participants whose QP changed this sync:

- Non-scoring / Top 8 draw from a new per-league `scoreboard` (all drafted
  participants), scoped to the sports season being announced so a golf pick can't
  leak into a tennis event. Points Awarded / Knocked Out stay scoped to the sync's
  changes and remain the only pinged sections.
- Non-scoring is now a single compact "Name (points, manager)" line below Top 8,
  covering everyone not in the top 8 (the exact complement of the Top 8 filter).
- Top 8 requires qpTotal > 0 as well as rank <= 8, so early-season winless players
  tied into a low rank band no longer flood the section with "T5. Name — 0 QP".

Manually setting a bracket match result (e.g. a Wimbledon semifinal) now announces
the QP update. The set-winner / set-round-winners / complete-round paths route
through processQualifyingEvent (which snapshots, diffs, and notifies) instead of
processQualifyingBracketEvent (which scored silently). The tournament fan-out still
skips the primary window via skipEventId, so mirror windows aren't double-posted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:04:22 -07:00

498 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/discord", () => ({
sendQualifyingPointsUpdateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/account", () => ({
findDiscordIdsByUserIds: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("~/models/user", () => ({
getUserDisplayName: vi.fn((u: { username?: string }) => u.username ?? null),
}));
import { notifyQualifyingPointsUpdate } from "../qualifying-points-discord.server";
import { sendQualifyingPointsUpdateNotification } from "~/services/discord";
import { findDiscordIdsByUserIds } from "~/models/account";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SPORTS_SEASON_ID = "ss-1";
const SCORING_EVENT_ID = "ev-1";
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const PARTICIPANT_ID = "p-1";
const OWNER_ID = "u-1";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
function makeEvent(overrides = {}) {
return {
id: SCORING_EVENT_ID,
name: "Roland Garros 2025",
sportsSeason: { sport: { name: "Tennis" } },
...overrides,
};
}
function makeDb(overrides: Record<string, unknown> = {}) {
return {
query: {
scoringEvents: {
findFirst: vi.fn().mockResolvedValue(makeEvent()),
},
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }]),
},
eventResults: {
findMany: vi.fn().mockResolvedValue([
{
seasonParticipantId: PARTICIPANT_ID,
qualifyingPointsAwarded: "10",
scoringEventId: SCORING_EVENT_ID,
},
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{
id: SEASON_ID,
year: 2025,
league: { id: LEAGUE_ID, name: "Slam League", discordWebhookUrl: WEBHOOK_URL },
},
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]),
},
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: false },
]),
},
...overrides,
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map());
});
describe("notifyQualifyingPointsUpdate", () => {
it("returns early when the scoring event is not found", async () => {
const db = makeDb({
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no season sports exist for the sports season", async () => {
const db = makeDb({
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("returns early when no qualifying points were awarded in the event", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: null },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues without a Discord webhook URL", async () => {
const db = makeDb({
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "Slam League", discordWebhookUrl: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("skips leagues where no QP participants were drafted", async () => {
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("calls sendQualifyingPointsUpdateNotification with correct participant data", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
webhookUrl: WEBHOOK_URL,
seasonName: "Slam League 2025",
eventName: "Roland Garros 2025",
sportName: "Tennis",
standingsUrl: `${process.env.APP_URL ?? "https://brackt.com"}/leagues/${LEAGUE_ID}/sports-seasons/${SPORTS_SEASON_ID}`,
entries: [
expect.objectContaining({
participantName: "Carlos Alcaraz",
qpEarned: 10,
qpTotal: 45,
ownerUsername: "chris",
ownerDiscordUserId: undefined,
}),
],
})
);
});
it("sends once per league, not once per participant", async () => {
const db = makeDb({
seasonSports: {
findMany: vi.fn().mockResolvedValue([{ seasonId: SEASON_ID }, { seasonId: "season-2" }]),
},
seasons: {
findMany: vi.fn().mockResolvedValue([
{ id: SEASON_ID, year: 2025, league: { name: "League A", discordWebhookUrl: WEBHOOK_URL } },
{ id: "season-2", year: 2025, league: { name: "League B", discordWebhookUrl: "https://discord.com/api/webhooks/456/def" } },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: PARTICIPANT_ID, seasonId: "season-2", team: { name: "Beta FC", ownerId: null } },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledTimes(2);
});
it("resolves owner Discord ID and passes it when user has discordPingEnabled", async () => {
vi.mocked(findDiscordIdsByUserIds).mockResolvedValue(new Map([[OWNER_ID, "discord-999"]]));
const db = makeDb({
users: {
findMany: vi.fn().mockResolvedValue([
{ id: OWNER_ID, username: "chris", discordPingEnabled: true },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: "discord-999" })],
})
);
});
it("omits ownerDiscordUserId when user has discordPingEnabled false", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
expect(findDiscordIdsByUserIds).toHaveBeenCalledWith([]);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
entries: [expect.objectContaining({ ownerDiscordUserId: undefined })],
})
);
});
it("participantIdFilter limits notification to only specified participants", async () => {
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
});
it("scoreboard includes every drafted participant even when entries are filtered", async () => {
// The scoreboard powers the Top 8 / Non-scoring sections and must reflect the full
// drafted field, not just this sync's changed participants. Here Nadal (p-2) did not
// change this sync (filtered out of entries) but is drafted, so he belongs on the
// scoreboard with his running total and no QP earned this event.
const db = makeDb({
eventResults: {
findMany: vi.fn().mockResolvedValue([
{ seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" },
{ seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" },
]),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } },
]),
},
seasonParticipantQualifyingTotals: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID },
{ participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
// entries is scoped to the changed participant…
expect(call.entries).toHaveLength(1);
expect(call.entries[0].participantName).toBe("Carlos Alcaraz");
// …but the scoreboard carries the whole drafted field.
expect(call.scoreboard).toEqual(
expect.arrayContaining([
expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }),
expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }),
])
);
expect(call.scoreboard).toHaveLength(2);
});
it("excludes participants from other sports seasons drafted in the same fantasy season", async () => {
// Draft picks span every sport in a fantasy season, so a golf pick can share the
// league with this tennis event. It must not leak into the tennis scoreboard.
const db = makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{ participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
{ participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } },
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" },
]),
},
});
await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.scoreboard).toHaveLength(1);
expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz");
});
it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"])
);
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
// A knocked-out drafted player (e.g. a 2nd-round loser) earns no QP and so has
// no event_results row — surfaced only via the eliminatedParticipantIds arg.
const MENSIK_ID = "p-2";
function makeDbWithEliminated() {
return makeDb({
draftPicks: {
findMany: vi.fn().mockResolvedValue([
{
participantId: PARTICIPANT_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
{
participantId: MENSIK_ID,
seasonId: SEASON_ID,
team: { id: "t-1", name: "Alpha FC", ownerId: OWNER_ID },
},
]),
},
seasonParticipants: {
findMany: vi.fn().mockResolvedValue([
{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID },
{ id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID },
]),
},
});
}
it("announces a knocked-out drafted player who earned no QP", async () => {
const db = makeDbWithEliminated();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledWith(
expect.objectContaining({
eliminated: [
expect.objectContaining({ participantName: "Jakob Mensik", ownerUsername: "chris" }),
],
})
);
});
it("does not announce a knocked-out player who is not drafted in the league", async () => {
const db = makeDb();
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set(["p-undrafted"])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.eliminated).toEqual([]);
});
it("does not double-list a QP earner that is also passed as eliminated", async () => {
const db = makeDb();
// PARTICIPANT_ID earned 10 QP this sync AND is passed as eliminated
// (e.g. a Round-of-16 loss). It should stay in entries, not the eliminated list.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set([PARTICIPANT_ID]),
new Set([PARTICIPANT_ID])
);
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toHaveLength(1);
expect(call.eliminated).toEqual([]);
});
it("short-circuits before the QP-total/season lookups when nothing drafted is involved", async () => {
// A knockout occurred but the player isn't drafted in any league. The notifier
// is invoked (the sync fires it on any elimination) but must not run the rest
// of its query battery just to send nothing.
const db = makeDb({
draftPicks: { findMany: vi.fn().mockResolvedValue([]) },
});
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set(["p-undrafted"])
);
expect(db.query.draftPicks.findMany).toHaveBeenCalledOnce();
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
expect(db.query.seasons.findMany).not.toHaveBeenCalled();
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
expect(sendQualifyingPointsUpdateNotification).not.toHaveBeenCalled();
});
it("fires when a drafted player is knocked out even though no QP changed", async () => {
const db = makeDbWithEliminated();
// participantIdFilter matches nobody → no QP entries, but a knockout exists.
await notifyQualifyingPointsUpdate(
SPORTS_SEASON_ID,
SCORING_EVENT_ID,
db as never,
new Set(["p-nonexistent"]),
new Set([MENSIK_ID])
);
expect(sendQualifyingPointsUpdateNotification).toHaveBeenCalledOnce();
const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0];
expect(call.entries).toEqual([]);
expect(call.eliminated).toEqual([
expect.objectContaining({ participantName: "Jakob Mensik" }),
]);
});
});