2026-07-01 17:29:45 +00:00
|
|
|
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 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: { 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" }]),
|
|
|
|
|
},
|
|
|
|
|
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",
|
|
|
|
|
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" },
|
|
|
|
|
{ id: "p-2", name: "Rafael Nadal" },
|
|
|
|
|
]),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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("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();
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
// 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" },
|
|
|
|
|
{ id: MENSIK_ID, name: "Jakob Mensik" },
|
|
|
|
|
]),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 15:10:07 +00:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
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("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" }),
|
|
|
|
|
]);
|
|
|
|
|
});
|
2026-07-01 17:29:45 +00:00
|
|
|
});
|