Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/matches/jobs/CancelExpiredMatches.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { CancelExpiredMatches } from "./CancelExpiredMatches";

const expiredTournamentMatch = (overrides: Record<string, any> = {}) => ({
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();
});
});
72 changes: 63 additions & 9 deletions src/matches/jobs/CancelExpiredMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<ReturnType<typeof this.getTournamentMatches>>[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<ReturnType<typeof this.getTournamentMatches>>[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: {
Expand All @@ -84,6 +98,43 @@ export class CancelExpiredMatches extends WorkerHost {
});
}

private getWinningLineupId(
match: Awaited<ReturnType<typeof this.getTournamentMatches>>[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: {
Expand All @@ -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,
Expand Down