diff --git a/src/matches/jobs/CancelExpiredMatches.spec.ts b/src/matches/jobs/CancelExpiredMatches.spec.ts new file mode 100644 index 00000000..08526487 --- /dev/null +++ b/src/matches/jobs/CancelExpiredMatches.spec.ts @@ -0,0 +1,198 @@ +import { CancelExpiredMatches } from "./CancelExpiredMatches"; +import { DISCORD_COLORS } from "../../notifications/utilities/constants"; + +const expiredTournamentMatch = (overrides: Record = {}) => ({ + id: "match-1", + is_tournament_match: true, + options: { + match_mode: "auto", + }, + lineup_1: { + id: "lineup-1", + is_ready: false, + }, + lineup_2: { + id: "lineup-2", + is_ready: false, + }, + ...overrides, +}); + +describe("CancelExpiredMatches", () => { + const logger = { + log: jest.fn(), + }; + const hasura = { + mutation: jest.fn(), + query: jest.fn(), + }; + const notifications = { + send: jest.fn(), + }; + const configService = { + get: jest.fn(), + }; + + let job: CancelExpiredMatches; + let tournamentMatches: any[]; + let pendingNotificationCount: number; + + beforeEach(() => { + jest.clearAllMocks(); + tournamentMatches = []; + pendingNotificationCount = 0; + hasura.mutation.mockResolvedValue({ + update_matches: { + affected_rows: 0, + }, + }); + hasura.query.mockImplementation(async (query: any) => { + if (query.notifications_aggregate) { + return { + notifications_aggregate: { + aggregate: { count: pendingNotificationCount }, + }, + }; + } + return { matches: tournamentMatches }; + }); + configService.get.mockReturnValue({ webDomain: "https://example.com" }); + job = new CancelExpiredMatches( + logger as any, + hasura as any, + notifications as any, + configService as any, + ); + }); + + it("requests organizer attention for admin-mode tournament matches when neither lineup is ready", async () => { + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + }), + ]; + + await expect(job.process()).resolves.toBe(1); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + pk_columns: { + id: "match-1", + }, + _set: { + cancels_at: null, + }, + }), + }), + }), + ); + expect(hasura.mutation).not.toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: expect.objectContaining({ + status: "Forfeit", + }), + }), + }), + }), + ); + expect(notifications.send).toHaveBeenCalledWith( + "MatchSupport", + expect.objectContaining({ + message: expect.stringContaining( + 'href="https://example.com/matches/match-1"', + ), + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: "match-1", + }), + undefined, + DISCORD_COLORS.RED, + ); + }); + + it("does not re-notify when an organizer notification is already pending", async () => { + pendingNotificationCount = 1; + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + }), + ]; + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: { + cancels_at: null, + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); + + it("forfeits auto-mode tournament matches when neither lineup is ready", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.25); + tournamentMatches = [expiredTournamentMatch()]; + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + pk_columns: { + id: "match-1", + }, + _set: { + status: "Forfeit", + winning_lineup_id: "lineup-1", + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); + + it("forfeits to the ready lineup even in admin mode", async () => { + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + lineup_2: { + id: "lineup-2", + is_ready: true, + }, + }), + ]; + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: { + status: "Forfeit", + winning_lineup_id: "lineup-2", + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/matches/jobs/CancelExpiredMatches.ts b/src/matches/jobs/CancelExpiredMatches.ts index ab89f3f1..7e893620 100644 --- a/src/matches/jobs/CancelExpiredMatches.ts +++ b/src/matches/jobs/CancelExpiredMatches.ts @@ -1,16 +1,25 @@ import { Logger } from "@nestjs/common"; import { WorkerHost } from "@nestjs/bullmq"; +import { ConfigService } from "@nestjs/config"; import { MatchQueues } from "../enums/MatchQueues"; import { UseQueue } from "../../utilities/QueueProcessors"; import { HasuraService } from "../../hasura/hasura.service"; +import { NotificationsService } from "../../notifications/notifications.service"; +import { AppConfig } from "../../configs/types/AppConfig"; +import { DISCORD_COLORS } from "../../notifications/utilities/constants"; @UseQueue("Matches", MatchQueues.ScheduledMatches) export class CancelExpiredMatches extends WorkerHost { + private readonly appConfig: AppConfig; + constructor( private readonly logger: Logger, private readonly hasura: HasuraService, + private readonly notifications: NotificationsService, + private readonly configService: ConfigService, ) { super(); + this.appConfig = this.configService.get("app"); } async process(): Promise { const { update_matches } = await this.hasura.mutation({ @@ -50,25 +59,37 @@ export class CancelExpiredMatches extends WorkerHost { const tournamentMatches = await this.getTournamentMatches(); for (const tournamentMatch of tournamentMatches) { - await this.forfeitMatch(tournamentMatch); + await this.handleExpiredTournamentMatch(tournamentMatch); } - const totalCanceledMatches = + const totalExpiredMatches = update_matches.affected_rows + tournamentMatches.length; - if (totalCanceledMatches > 0) { - this.logger.log(`canceled ${totalCanceledMatches} matches`); + if (totalExpiredMatches > 0) { + this.logger.log(`processed ${totalExpiredMatches} expired matches`); } - return totalCanceledMatches; + return totalExpiredMatches; + } + + private async handleExpiredTournamentMatch( + match: Awaited>[number], + ) { + const hasReadyLineup = match.lineup_1.is_ready || match.lineup_2.is_ready; + const isAdminMode = match.options?.match_mode === "admin"; + + if (!hasReadyLineup && isAdminMode) { + await this.requestOrganizerAttention(match.id); + return; + } + + await this.forfeitMatch(match); } private async forfeitMatch( match: Awaited>[number], ) { - const winningLineupId = match.lineup_1.is_ready - ? match.lineup_1.id - : match.lineup_2.id; - void this.hasura.mutation({ + const winningLineupId = this.getWinningLineupId(match); + await this.hasura.mutation({ update_matches_by_pk: { __args: { pk_columns: { @@ -84,6 +105,74 @@ export class CancelExpiredMatches extends WorkerHost { }); } + private getWinningLineupId( + match: Awaited>[number], + ) { + if (match.lineup_1.is_ready) { + return match.lineup_1.id; + } + + if (match.lineup_2.is_ready) { + return match.lineup_2.id; + } + + // Neither side checked in. In auto mode there is no one watching the + // bracket, so coin-toss a winner to keep the tournament moving rather + // than stalling it (admin mode routes to a human instead). + return Math.random() < 0.5 ? match.lineup_1.id : match.lineup_2.id; + } + + private async requestOrganizerAttention(matchId: string) { + await this.hasura.mutation({ + update_matches_by_pk: { + __args: { + pk_columns: { + id: matchId, + }, + _set: { + cancels_at: null, + }, + }, + __typename: true, + }, + }); + + if (await this.hasPendingOrganizerNotification(matchId)) { + return; + } + + await this.notifications.send( + "MatchSupport", + { + message: `Tournament match requires admin attention ${matchId}`, + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: matchId, + }, + undefined, + DISCORD_COLORS.RED, + ); + } + + private async hasPendingOrganizerNotification(matchId: string) { + const { notifications_aggregate } = await this.hasura.query({ + notifications_aggregate: { + __args: { + where: { + entity_id: { _eq: matchId }, + type: { _eq: "MatchSupport" }, + is_read: { _eq: false }, + }, + }, + aggregate: { + count: true, + }, + }, + }); + + return notifications_aggregate.aggregate.count > 0; + } + private async getTournamentMatches() { const { matches } = await this.hasura.query({ matches: { @@ -110,6 +199,9 @@ export class CancelExpiredMatches extends WorkerHost { }, id: true, is_tournament_match: true, + options: { + match_mode: true, + }, lineup_1: { id: true, is_ready: true,