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
112 changes: 112 additions & 0 deletions src/notifications/notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,115 @@ describe("latestTitle", () => {
).resolves.toBeNull();
});
});

// Match alerts reach a match's organizers by steam id, so the bell switch for
// them has to be honoured at insert like every other per-player type.
describe("match alerts to organizers", () => {
const ORGANIZER = "76561198000000001";
const CO_ORGANIZER = "76561198000000002";

let hasura: { query: jest.Mock; mutation: jest.Mock };
let preferences: { filterInAppRecipients: jest.Mock };
let pushNotifications: {
filterSubscribed: jest.Mock;
claimFanOut: jest.Mock;
};
let service: NotificationsService;

const organizedBy = (tournament: Record<string, unknown> | null) =>
hasura.query.mockImplementation(async (query: any) => {
if (query.tournament_brackets) {
return {
tournament_brackets: tournament ? [{ stage: { tournament } }] : [],
};
}
if (query.matches_by_pk) {
return { matches_by_pk: { organizer_steam_id: ORGANIZER } };
}
return { settings_by_pk: null };
});

const writtenRows = () =>
hasura.mutation.mock.calls.flatMap(([mutation]: [any]) =>
mutation.insert_notifications
? mutation.insert_notifications.__args.objects
: [mutation.insert_notifications_one.__args.object],
);

beforeEach(() => {
hasura = {
query: jest.fn(),
mutation: jest.fn(async (mutation: any) => ({
insert_notifications: {
returning: (mutation.insert_notifications?.__args.objects ?? []).map(
(_: unknown, index: number) => ({ id: `row-${index}` }),
),
},
insert_notifications_one: { id: "row" },
})),
};
preferences = {
filterInAppRecipients: jest.fn(async (): Promise<string[]> => []),
};
pushNotifications = {
filterSubscribed: jest.fn(async (): Promise<string[]> => []),
claimFanOut: jest.fn().mockResolvedValue(undefined),
};

service = new NotificationsService(
hasura as any,
{ query: jest.fn().mockResolvedValue([]) } as any,
{ log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any,
{ get: () => ({ webDomain: "https://5stack.gg" }) } as any,
preferences as any,
pushNotifications as any,
{ add: jest.fn() } as any,
{ add: jest.fn().mockResolvedValue({}) } as any,
);
});

it.each([
["a paused map", "sendMatchMapPauseNotification"],
["a match waiting for a server", "sendMatchWaitingForServerNotification"],
] as const)(
"keeps %s out of the bell of an organizer who muted it",
async (_, send) => {
organizedBy(null);

await service[send]("match-1");

expect(preferences.filterInAppRecipients).toHaveBeenCalledWith(
"MatchStatusChange",
[ORGANIZER],
);
expect(writtenRows().filter((row: any) => row.steam_id)).toEqual([]);
expect(writtenRows().map((row: any) => row.role)).toEqual([
"match_organizer",
]);
},
);

it("asks every tournament organizer's bell, and writes only for those listening", async () => {
organizedBy({
id: "tournament-1",
name: "Autumn Cup",
organizer_steam_id: ORGANIZER,
organizers: [{ steam_id: CO_ORGANIZER }],
discord_notify_MapPaused: false,
});
preferences.filterInAppRecipients.mockResolvedValue([CO_ORGANIZER]);

await service.sendMatchMapPauseNotification("match-1");

expect(preferences.filterInAppRecipients).toHaveBeenCalledWith(
"MatchStatusChange",
[ORGANIZER, CO_ORGANIZER],
);
expect(
writtenRows().map((row: any) => [row.steam_id ?? null, row.role]),
).toEqual([
[CO_ORGANIZER, "tournament_organizer"],
[null, "administrator"],
]);
});
});
47 changes: 20 additions & 27 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,13 +925,12 @@ export class NotificationsService {
}

if (matches_by_pk.organizer_steam_id) {
await this.insertNotification({
type: "MatchStatusChange",
await this.notifyPlayers("MatchStatusChange", {
title,
message,
steam_id: matches_by_pk.organizer_steam_id,
role: "user",
entity_id: matchId,
steamIds: [String(matches_by_pk.organizer_steam_id)],
});
}

Expand Down Expand Up @@ -968,16 +967,13 @@ export class NotificationsService {
organizerSteamIds.add(String(org.steam_id));
}

for (const steamId of organizerSteamIds) {
await this.insertNotification({
type: "MatchStatusChange",
title,
message,
steam_id: steamId,
role: "tournament_organizer",
entity_id: matchId,
});
}
await this.notifyPlayers("MatchStatusChange", {
title,
message,
role: "tournament_organizer",
entity_id: matchId,
steamIds: Array.from(organizerSteamIds),
});

await this.insertNotification({
type: "MatchStatusChange",
Expand Down Expand Up @@ -1056,13 +1052,12 @@ export class NotificationsService {
}

if (matches_by_pk.organizer_steam_id) {
await this.insertNotification({
type: "MatchStatusChange",
await this.notifyPlayers("MatchStatusChange", {
title,
message,
steam_id: matches_by_pk.organizer_steam_id,
role: "user",
entity_id: matchId,
steamIds: [String(matches_by_pk.organizer_steam_id)],
});
}

Expand Down Expand Up @@ -1100,16 +1095,13 @@ export class NotificationsService {
organizerSteamIds.add(String(org.steam_id));
}

for (const steamId of organizerSteamIds) {
await this.insertNotification({
type: "MatchStatusChange",
title,
message,
steam_id: steamId,
role: "tournament_organizer",
entity_id: matchId,
});
}
await this.notifyPlayers("MatchStatusChange", {
title,
message,
role: "tournament_organizer",
entity_id: matchId,
steamIds: Array.from(organizerSteamIds),
});

await this.insertNotification({
type: "MatchStatusChange",
Expand Down Expand Up @@ -1141,13 +1133,14 @@ export class NotificationsService {
}
}

// Role broadcasts only: a row for one player goes through notifyPlayers,
// which is where their bell preference is applied.
private async insertNotification(notification: {
type: e_notification_types_enum;
title: string;
message: string;
entity_id?: string;
role: e_player_roles_enum;
steam_id?: string;
deletable?: boolean;
}) {
await this.hasura.mutation({
Expand Down
130 changes: 130 additions & 0 deletions src/notifications/preferences/notification-categories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
IN_APP_KEYS,
pushCategoryForType,
inAppKeyForType,
isKnownKey,
bellControlForType,
typesForCategory,
} from "./notification-categories";

const HASURA_DIR = join(__dirname, "../../../hasura");
Expand Down Expand Up @@ -37,6 +40,29 @@ const notificationTypesInTree = (): string[] => {
return [...types].sort();
};

// The bell may mute any notice about the player except their own name-change
// decisions, sanctions and warnings.
const ACCOUNT_AND_SAFETY = [
"NameChangeApproved",
"NameChangeDenied",
"PlayerSanctioned",
"PlayerWarning",
];

const NEVER_IN_THE_BELL = [
"MatchFound",
"AdminCall",
"ChatMessage",
"MatchChatMessage",
];

const staffTypes = () =>
new Set<string>(
PUSH_KEYS.filter((entry) => entry.adminOnly).flatMap(
(entry) => PUSH_CATEGORIES[entry.key],
),
);

describe("notification categories", () => {
const types = notificationTypesInTree();

Expand Down Expand Up @@ -149,4 +175,108 @@ describe("notification categories", () => {

expect(inAppKeyForType("GameUpdate")).toBeNull();
});

it("gives every per-player type a bell switch except a player's own account and safety notices", () => {
const staff = staffTypes();
const perPlayer = types.filter(
(type) =>
!staff.has(type) &&
!ACCOUNT_AND_SAFETY.includes(type) &&
!NEVER_IN_THE_BELL.includes(type),
);

expect(IN_APP_KEYS.map((entry) => entry.key).sort()).toEqual(
perPlayer.sort(),
);
});

it("refuses a bell preference for a locked or push-only type", () => {
for (const type of [...ACCOUNT_AND_SAFETY, ...NEVER_IN_THE_BELL]) {
expect(isKnownKey("in_app", type)).toBe(false);
}

expect(isKnownKey("in_app", "ScrimRequestReceived")).toBe(true);
expect(isKnownKey("in_app", "MatchStatusChange")).toBe(true);
});
});

describe("bell control", () => {
const types = notificationTypesInTree();

it("classifies every notification type", () => {
const unclassified = types.filter((type) => !bellControlForType(type));

expect(unclassified).toEqual([]);
});

it("locks exactly a player's own account and safety notices", () => {
const staff = staffTypes();
const locked = types.filter(
(type) => !staff.has(type) && bellControlForType(type) === "locked",
);

expect(locked.sort()).toEqual([...ACCOUNT_AND_SAFETY].sort());
});

it("locks staff broadcasts, which have no recipient list to filter", () => {
const unlocked = [...staffTypes()].filter(
(type) => bellControlForType(type) !== "locked",
);

expect(unlocked).toEqual([]);
});

it("keeps the rings and chat out of the bell", () => {
expect(NEVER_IN_THE_BELL.map(bellControlForType)).toEqual([
"push_only",
"push_only",
"push_only",
"push_only",
]);
});

it("toggles exactly the in-app keys", () => {
const toggles = types.filter(
(type) => bellControlForType(type) === "toggle",
);

expect(toggles.sort()).toEqual(
IN_APP_KEYS.map((entry) => entry.key).sort(),
);
});
});

describe("category catalog", () => {
const types = notificationTypesInTree();

it("lists a category's types in order, with how the bell treats each", () => {
expect(typesForCategory("account")).toEqual([
{ type: "NameChangeApproved", bell: "locked", ignoresQuietHours: false },
{ type: "NameChangeDenied", bell: "locked", ignoresQuietHours: false },
{ type: "PlayerSanctioned", bell: "locked", ignoresQuietHours: false },
{ type: "PlayerWarning", bell: "locked", ignoresQuietHours: false },
{ type: "AwardGranted", bell: "toggle", ignoresQuietHours: false },
]);
});

it("says which types ring through quiet hours", () => {
expect(typesForCategory("match_found")).toEqual([
{ type: "MatchFound", bell: "push_only", ignoresQuietHours: true },
]);
expect(typesForCategory("admin_call")).toEqual([
{ type: "AdminCall", bell: "push_only", ignoresQuietHours: true },
]);
});

it("lists every type exactly once across the push categories", () => {
const listed = PUSH_KEYS.flatMap((entry) =>
typesForCategory(entry.key).map(({ type }) => type),
);

expect([...listed].sort()).toEqual([...types].sort());
});

it("lists nothing for a category that does not exist", () => {
expect(typesForCategory("nope")).toEqual([]);
});
});
Loading
Loading