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 = {}) { 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(); }); // 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([]); }); 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" }), ]); }); });