diff --git a/src/matches/jobs/CancelExpiredMatches.spec.ts b/src/matches/jobs/CancelExpiredMatches.spec.ts new file mode 100644 index 00000000..1283dec1 --- /dev/null +++ b/src/matches/jobs/CancelExpiredMatches.spec.ts @@ -0,0 +1,154 @@ +import { CancelExpiredMatches } from "./CancelExpiredMatches"; + +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(), + }; + + let job: CancelExpiredMatches; + + beforeEach(() => { + jest.clearAllMocks(); + hasura.mutation.mockResolvedValue({ + update_matches: { + affected_rows: 0, + }, + }); + hasura.query.mockResolvedValue({ + matches: [], + }); + job = new CancelExpiredMatches( + logger as any, + hasura as any, + notifications as any, + ); + }); + + it("requests organizer attention for admin-mode tournament matches when neither lineup is ready", async () => { + hasura.query.mockResolvedValue({ + matches: [ + 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", { + message: "Tournament match requires admin attention: match-1", + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: "match-1", + }); + }); + + it("forfeits auto-mode tournament matches when neither lineup is ready", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.25); + hasura.query.mockResolvedValue({ + matches: [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 () => { + hasura.query.mockResolvedValue({ + matches: [ + 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..f4a6e97e 100644 --- a/src/matches/jobs/CancelExpiredMatches.ts +++ b/src/matches/jobs/CancelExpiredMatches.ts @@ -3,12 +3,14 @@ import { WorkerHost } from "@nestjs/bullmq"; import { MatchQueues } from "../enums/MatchQueues"; import { UseQueue } from "../../utilities/QueueProcessors"; import { HasuraService } from "../../hasura/hasura.service"; +import { NotificationsService } from "../../notifications/notifications.service"; @UseQueue("Matches", MatchQueues.ScheduledMatches) export class CancelExpiredMatches extends WorkerHost { constructor( private readonly logger: Logger, private readonly hasura: HasuraService, + private readonly notifications: NotificationsService, ) { super(); } @@ -50,25 +52,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 +98,43 @@ 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; + } + + 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, + }, + }); + + await this.notifications.send("MatchSupport", { + message: `Tournament match requires admin attention: ${matchId}`, + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: matchId, + }); + } + private async getTournamentMatches() { const { matches } = await this.hasura.query({ matches: { @@ -110,6 +161,9 @@ export class CancelExpiredMatches extends WorkerHost { }, id: true, is_tournament_match: true, + options: { + match_mode: true, + }, lineup_1: { id: true, is_ready: true,