Skip to content
Merged
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
198 changes: 198 additions & 0 deletions src/matches/jobs/CancelExpiredMatches.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { CancelExpiredMatches } from "./CancelExpiredMatches";
import { DISCORD_COLORS } from "../../notifications/utilities/constants";

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(),
};
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();
});
});
110 changes: 101 additions & 9 deletions src/matches/jobs/CancelExpiredMatches.ts
Original file line number Diff line number Diff line change
@@ -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<AppConfig>("app");
}
async process(): Promise<number> {
const { update_matches } = await this.hasura.mutation({
Expand Down Expand Up @@ -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<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 +105,74 @@ 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;
}

// 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 <a href="${this.appConfig.webDomain}/matches/${matchId}">${matchId}</a>`,
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: {
Expand All @@ -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,
Expand Down