diff --git a/generated/schema.graphql b/generated/schema.graphql index c9e24b45..256d2297 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -14756,7 +14756,7 @@ enum e_notification_types_enum { """Player search reindex finished""" PlayerReindex - """A player you recently played with received a sanction""" + """You were banned, or (for staff) a player was banned""" PlayerSanctioned """A moderator issued you a warning""" @@ -14798,6 +14798,9 @@ enum e_notification_types_enum { """You were invited to a team""" TeamInvite + """A player you played with in the last 6 months was banned""" + TeammateBanned + """Check-in for your tournament closes soon""" TournamentCheckInClosing diff --git a/generated/schema.ts b/generated/schema.ts index 623429d0..de98d62e 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -6227,7 +6227,7 @@ export interface e_notification_types_aggregate_fields { /** unique or primary key constraints on table "e_notification_types" */ export type e_notification_types_constraint = 'e_notification_types_pkey' -export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'PlayerWarning' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' +export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'PlayerWarning' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TeammateBanned' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' /** aggregate max on columns */ @@ -162460,6 +162460,7 @@ export const enumENotificationTypesEnum = { SeasonEnded: 'SeasonEnded' as const, StorageScan: 'StorageScan' as const, TeamInvite: 'TeamInvite' as const, + TeammateBanned: 'TeammateBanned' as const, TournamentCheckInClosing: 'TournamentCheckInClosing' as const, TournamentCheckInMissed: 'TournamentCheckInMissed' as const, TournamentCheckInOpen: 'TournamentCheckInOpen' as const, diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 4336afd3..780df113 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -10,7 +10,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('DedicatedServerRconStatus', 'DedicatedServerRconStatus'), ('MatchStatusChange', 'Match Status Change Notification'), ('StorageScan', 'Storage Scan'), - ('PlayerSanctioned', 'A player you recently played with received a sanction'), + ('PlayerSanctioned', 'You were banned, or (for staff) a player was banned'), ('ScrimRequestReceived', 'A team requested to scrim yours'), ('ScrimRequestCountered', 'A team proposed a different scrim time'), ('ScrimRequestAccepted', 'Your scrim request was accepted'), @@ -52,6 +52,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party'), ('PlayerWarning', 'A moderator issued you a warning'), ('MatchFound', 'A match was found and is waiting for you to ready up'), - ('AdminCall', 'An admin is calling you') + ('AdminCall', 'An admin is calling you'), + ('TeammateBanned', 'A player you played with in the last 6 months was banned') ON CONFLICT("value") DO UPDATE SET "description" = EXCLUDED."description"; diff --git a/hasura/migrations/default/1890000000100_teammate_bans_preference/down.sql b/hasura/migrations/default/1890000000100_teammate_bans_preference/down.sql new file mode 100644 index 00000000..d70d6d96 --- /dev/null +++ b/hasura/migrations/default/1890000000100_teammate_bans_preference/down.sql @@ -0,0 +1,3 @@ +DELETE FROM public.notification_preferences + WHERE channel = 'push' + AND key = 'teammate_bans'; diff --git a/hasura/migrations/default/1890000000100_teammate_bans_preference/up.sql b/hasura/migrations/default/1890000000100_teammate_bans_preference/up.sql new file mode 100644 index 00000000..ee6eff88 --- /dev/null +++ b/hasura/migrations/default/1890000000100_teammate_bans_preference/up.sql @@ -0,0 +1,8 @@ +-- Banned-teammate pushes used to be part of account, so an account opt-out carries over. +INSERT INTO public.notification_preferences (steam_id, channel, key, enabled) +SELECT steam_id, channel, 'teammate_bans', false + FROM public.notification_preferences + WHERE channel = 'push' + AND key = 'account' + AND enabled = false +ON CONFLICT (steam_id, channel, key) DO NOTHING; diff --git a/src/notifications/jobs/SendSanctionNotifications.spec.ts b/src/notifications/jobs/SendSanctionNotifications.spec.ts new file mode 100644 index 00000000..13faad26 --- /dev/null +++ b/src/notifications/jobs/SendSanctionNotifications.spec.ts @@ -0,0 +1,67 @@ +import { SendSanctionNotifications } from "./SendSanctionNotifications"; + +describe("SendSanctionNotifications", () => { + const ban = { + sanctionId: "sanction-1", + steamId: "76561198000000001", + type: "ban", + reason: "cheating", + }; + + let order: string[]; + let notifications: Record; + let logger: { error: jest.Mock }; + let job: SendSanctionNotifications; + + const step = (name: string) => + jest.fn(async () => { + order.push(name); + }); + + beforeEach(() => { + order = []; + notifications = { + notifyBannedPlayer: step("banned player"), + notifyWarnedPlayer: step("warned player"), + notifyAdminsOfBan: step("admins"), + notifyMatchPlayersOfSanction: step("co-players"), + }; + logger = { error: jest.fn() }; + job = new SendSanctionNotifications(notifications as any, logger as any); + }); + + it("tells the banned player and the admins before the co-player fan-out", async () => { + await job.process({ data: ban } as any); + + expect(order).toEqual([ + "banned player", + "warned player", + "admins", + "co-players", + ]); + }); + + it("still alerts the admins when the co-player fan-out throws", async () => { + notifications.notifyMatchPlayersOfSanction.mockRejectedValue( + new Error("value 'TeammateBanned' for enum not found"), + ); + + await job.process({ data: ban } as any); + + expect(notifications.notifyAdminsOfBan).toHaveBeenCalledWith(ban); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it("still reaches the co-players when an earlier notice throws", async () => { + notifications.notifyBannedPlayer.mockRejectedValue(new Error("down")); + notifications.notifyAdminsOfBan.mockRejectedValue(new Error("down")); + + await job.process({ data: ban } as any); + + expect(notifications.notifyWarnedPlayer).toHaveBeenCalledWith(ban); + expect(notifications.notifyMatchPlayersOfSanction).toHaveBeenCalledWith( + ban, + ); + expect(logger.error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/notifications/jobs/SendSanctionNotifications.ts b/src/notifications/jobs/SendSanctionNotifications.ts index 44946af2..e9fad092 100644 --- a/src/notifications/jobs/SendSanctionNotifications.ts +++ b/src/notifications/jobs/SendSanctionNotifications.ts @@ -1,26 +1,51 @@ import { WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; import { Job } from "bullmq"; import { UseQueue } from "../../utilities/QueueProcessors"; import { NotificationsQueues } from "../enums/NotificationsQueues"; import { NotificationsService } from "../notifications.service"; +type SanctionNotification = { + sanctionId: string; + steamId: string; + type: string; + reason?: string | null; +}; + @UseQueue("Notifications", NotificationsQueues.SanctionNotifications) export class SendSanctionNotifications extends WorkerHost { - constructor(private readonly notifications: NotificationsService) { + constructor( + private readonly notifications: NotificationsService, + private readonly logger: Logger, + ) { super(); } - async process( - job: Job<{ - sanctionId: string; - steamId: string; - type: string; - reason?: string | null; - }>, - ): Promise { - await this.notifications.notifyBannedPlayer(job.data); - await this.notifications.notifyWarnedPlayer(job.data); - await this.notifications.notifyMatchPlayersOfSanction(job.data); - await this.notifications.notifyAdminsOfBan(job.data); + // The job runs once and its failed id is kept for an hour, so one notice + // failing must not cost the others; the co-player fan-out is the largest and + // goes last. + async process(job: Job): Promise { + const steps: Array< + [string, (data: SanctionNotification) => Promise] + > = [ + ["banned player", (data) => this.notifications.notifyBannedPlayer(data)], + ["warned player", (data) => this.notifications.notifyWarnedPlayer(data)], + ["admins", (data) => this.notifications.notifyAdminsOfBan(data)], + [ + "co-players", + (data) => this.notifications.notifyMatchPlayersOfSanction(data), + ], + ]; + + for (const [name, notify] of steps) { + try { + await notify(job.data); + } catch (error) { + this.logger.error( + `failed to notify ${name} of sanction ${job.data.sanctionId} on ${job.data.steamId}`, + error, + ); + } + } } } diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index c78caae0..ced96f80 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -50,6 +50,7 @@ describe("discord routing", () => { "ChatMessage", "MatchChatMessage", "PlayerSanctioned", + "TeammateBanned", "PlayerWarning", "MatchImported", "MatchFound", @@ -379,17 +380,49 @@ describe("NotificationsService", () => { }, ); - it("tells recent team-mates about a ban", async () => { + it("tells recent team-mates about a ban as a banned-teammate notice", async () => { await service.notifyMatchPlayersOfSanction(sanction("ban")); expect(notifyPlayers).toHaveBeenCalledTimes(1); const [type, notification] = notifyPlayers.mock.calls[0]; - expect(type).toBe("PlayerSanctioned"); + expect(type).toBe("TeammateBanned"); + expect(notification.title).toBe("Player Banned"); + expect(notification.entity_id).toBe("76561198000000001"); expect(notification.steamIds).toEqual(["76561198000000002"]); expect(notification.message).toContain( `keith, was banned. (cheating)`, ); }); + + it("tells them about an automatic Steam ban too", async () => { + await service.notifyMatchPlayersOfSanction({ + ...sanction("ban"), + reason: "VAC ban on record (1 ban)", + }); + + expect(notifyPlayers).toHaveBeenCalledTimes(1); + expect(notifyPlayers.mock.calls[0][0]).toBe("TeammateBanned"); + }); + }); + + describe("the banned player's own notice and the admin alert", () => { + const insertedTypes = () => + hasura.mutation.mock.calls.map( + ([mutation]) => mutation.insert_notifications.__args.objects[0].type, + ); + + it("stay on PlayerSanctioned", async () => { + hasura.query.mockResolvedValue({ + players_by_pk: { last_sign_in_at: "2026-09-01T00:00:00.000Z" }, + }); + postgres.query.mockResolvedValue([{ exists: true }]); + + await service.notifyBannedPlayer(sanction("ban")); + await service.notifyAdminsOfBan(sanction("ban")); + + expect(insertedTypes()).toEqual(["PlayerSanctioned", "PlayerSanctioned"]); + expect(notifyPlayers).not.toHaveBeenCalled(); + }); }); describe("warnings", () => { diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 7fe4701c..6fd270ee 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -171,8 +171,8 @@ export class NotificationsService { // months of team-mates is routinely hundreds of rows and the event trigger // fires per row, so every one of them resolved recipients and sent on its // own; notifyPlayers claims the burst so a single job covers it. - const notified = await this.notifyPlayers("PlayerSanctioned", { - title: "Player Sanctioned", + const notified = await this.notifyPlayers("TeammateBanned", { + title: "Player Banned", message, role: "user", entity_id: sanction.steamId, diff --git a/src/notifications/preferences/notification-categories.spec.ts b/src/notifications/preferences/notification-categories.spec.ts index e2b6a1fd..8020ce53 100644 --- a/src/notifications/preferences/notification-categories.spec.ts +++ b/src/notifications/preferences/notification-categories.spec.ts @@ -121,6 +121,27 @@ describe("notification categories", () => { expect(inAppKeyForType("MatchChatMessage")).toBeNull(); }); + it("gives banned teammates their own push category, on by default, after account", () => { + expect(pushCategoryForType("TeammateBanned")).toEqual({ + key: "teammate_bans", + defaultEnabled: true, + }); + + const keys = PUSH_KEYS.map((entry) => entry.key); + expect(keys.indexOf("teammate_bans")).toBe(keys.indexOf("account") + 1); + }); + + it("keeps a player's own sanction under account", () => { + expect(pushCategoryForType("PlayerSanctioned")?.key).toBe("account"); + }); + + it("lets the bell mute banned teammates, on by default", () => { + expect(inAppKeyForType("TeammateBanned")).toEqual({ + key: "TeammateBanned", + defaultEnabled: true, + }); + }); + it("resolves in-app keys back to their own type", () => { for (const entry of IN_APP_KEYS) { expect(inAppKeyForType(entry.key)).toEqual(entry); diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 010e96cc..e8913e2b 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -70,6 +70,7 @@ export const PUSH_CATEGORIES: Record = { "PlayerWarning", "AwardGranted", ], + teammate_bans: ["TeammateBanned"], news: ["NewsPublished"], staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"], staff_infrastructure: [ @@ -102,6 +103,7 @@ export const PUSH_KEYS: PreferenceKey[] = [ { key: "invites", defaultEnabled: true }, { key: "utility", defaultEnabled: true }, { key: "account", defaultEnabled: true }, + { key: "teammate_bans", defaultEnabled: true }, { key: "news", defaultEnabled: true }, { key: "staff_moderation", defaultEnabled: true, adminOnly: true }, // Infrastructure chatter is constant and rarely actionable on a phone. @@ -124,6 +126,7 @@ export const IN_APP_KEYS: PreferenceKey[] = [ { key: "MatchStatsReady", defaultEnabled: true }, { key: "ClipReady", defaultEnabled: true }, { key: "AwardGranted", defaultEnabled: true }, + { key: "TeammateBanned", defaultEnabled: true }, { key: "NewsPublished", defaultEnabled: true }, { key: "TournamentReminder", defaultEnabled: true }, { key: "TournamentCheckInOpen", defaultEnabled: true }, diff --git a/src/notifications/push/notification-delivery.spec.ts b/src/notifications/push/notification-delivery.spec.ts index 64dd3422..5fb955a8 100644 --- a/src/notifications/push/notification-delivery.spec.ts +++ b/src/notifications/push/notification-delivery.spec.ts @@ -86,6 +86,16 @@ describe("notification delivery policies", () => { }); }); + it("delivers a banned-teammate notice the way the sanction notice it split from was", () => { + expect(deliveryPolicyForType("TeammateBanned")).toEqual({ + bundleSeconds: 0, + requireUnseen: true, + }); + expect(deliveryPolicyForType("TeammateBanned")).toEqual( + deliveryPolicyForType("PlayerSanctioned"), + ); + }); + it.each(["MatchFound", "AdminCall"])( "rings %s now, even in quiet hours, and lets it expire with its window", (type) => { diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 7654d121..a007770b 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -63,6 +63,7 @@ const DELIVERY_POLICIES: Record = { "NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", + "TeammateBanned", "PlayerWarning", "TournamentReminder", "TournamentCheckInOpen", diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 52f7e904..86ed1429 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -1329,7 +1329,7 @@ describe("PushNotificationsService", () => { expect(PushNotificationsService.isBatched("TournamentCreated")).toBe(true); // A notifyPlayers fan-out rather than a notifyActivePlayers one, and the // largest of them: every co-player from six months of matches. - expect(PushNotificationsService.isBatched("PlayerSanctioned")).toBe(true); + expect(PushNotificationsService.isBatched("TeammateBanned")).toBe(true); expect(PushNotificationsService.isBatched("MatchStatusChange")).toBe(false); }); }); diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index d86e0c1d..02bbd763 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -204,9 +204,9 @@ const BATCHED_TYPES = new Set([ // fails -- and the point of the claim failing is to degrade to one batched // job, not to one send per player. "SeasonEnded", - // Six months of the sanctioned player's team-mates, every row carrying their + // Six months of the banned player's team-mates, every row carrying their // steam id as the entity, which is what the jobId collapses on. - "PlayerSanctioned", + "TeammateBanned", ]); const SEND_CHUNK_SIZE = 25; @@ -491,7 +491,7 @@ export class PushNotificationsService { // The id a burst collapses onto. Bucketed by time as well as by entity: the // entity alone is stable for a type that can happen to the same entity twice. - // PlayerSanctioned keys on the sanctioned player's steam id, so muting a + // The co-player sanction notice keys on the player's steam id, so muting a // player at 10:00 and banning them at 10:20 produced the same jobId, and // BullMQ rejected the second burst as a duplicate of the completed job it // retains for an hour -- nobody was told about the ban. Worst case a burst diff --git a/src/type-sense/type-sense.controller.spec.ts b/src/type-sense/type-sense.controller.spec.ts new file mode 100644 index 00000000..284ec660 --- /dev/null +++ b/src/type-sense/type-sense.controller.spec.ts @@ -0,0 +1,55 @@ +import { TypeSenseController } from "./type-sense.controller"; + +describe("TypeSenseController player_sanctions", () => { + const BANNED = "76561198000000001"; + + const controller = (notifications: { + queueSanctionNotification: jest.Mock; + }) => + new TypeSenseController( + {} as any, + { + query: jest.fn().mockResolvedValue({ match_lineup_players: [] }), + } as any, + { updatePlayer: jest.fn() } as any, + notifications as any, + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { add: jest.fn(), remove: jest.fn() } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + {} as any, + {} as any, + {} as any, + ); + + // There is no per-ban opt-in: whoever issued the ban, and whether anyone did, + // the co-player notice goes out and each player's own preference decides. + it.each([ + ["an automatic Steam ban", null, "VAC ban on record (1 ban)"], + ["a moderator's ban", "76561198000000009", "cheating"], + ])("queues the co-player notice for %s", async (_label, by, reason) => { + const notifications = { + queueSanctionNotification: jest.fn().mockResolvedValue(undefined), + }; + + await controller(notifications).player_sanctions({ + op: "INSERT", + old: null, + new: { + id: "sanction-1", + player_steam_id: BANNED, + sanctioned_by_steam_id: by, + type: "ban", + reason, + remove_sanction_date: null, + }, + } as any); + + expect(notifications.queueSanctionNotification).toHaveBeenCalledWith({ + sanctionId: "sanction-1", + steamId: BANNED, + type: "ban", + reason, + }); + }); +}); diff --git a/test/notifications.spec.ts b/test/notifications.spec.ts index 874fa233..93fd9521 100644 --- a/test/notifications.spec.ts +++ b/test/notifications.spec.ts @@ -279,6 +279,77 @@ describe("notifications (SQL-driven)", () => { }); }); + describe("banned teammates", () => { + const finishedMatchWith = async (steamIds: Array) => { + const { matchId } = await fx.bareMatch(new Date().toISOString()); + const [match] = await postgres.query< + Array<{ lineup_1_id: string; lineup_2_id: string }> + >(`SELECT lineup_1_id, lineup_2_id FROM matches WHERE id = $1`, [ + matchId, + ]); + for (const [index, steamId] of steamIds.entries()) { + await fx.lineupPlayer( + index % 2 === 0 ? match.lineup_1_id : match.lineup_2_id, + steamId, + ); + } + await postgres.query( + `UPDATE matches + SET started_at = now(), status = 'Finished', match_options_id = $2 + WHERE id = $1`, + [matchId, await fx.matchOptions()], + ); + }; + + const rows = async () => + postgres.query< + Array<{ + type: string; + steam_id: string; + entity_id: string; + in_app: boolean; + }> + >( + `SELECT type::text AS type, steam_id::text AS steam_id, entity_id, in_app + FROM notifications + ORDER BY steam_id`, + ); + + const ban = (steamId: string) => + notifications().notifyMatchPlayersOfSanction({ + sanctionId: "sanction-1", + steamId, + type: "ban", + reason: "VAC ban on record (1 ban)", + }); + + it("tells every co-player with its own type", async () => { + const [banned, teammate, opponent] = await fx.players(3); + await finishedMatchWith([banned, teammate, opponent]); + + await ban(banned); + + expect(await rows()).toEqual( + [teammate, opponent].sort().map((steamId) => ({ + type: "TeammateBanned", + steam_id: steamId, + entity_id: banned, + in_app: true, + })), + ); + }); + + it("skips a co-player who muted it in the bell and cannot be pushed", async () => { + const [banned, muted, listening] = await fx.players(3); + await finishedMatchWith([banned, muted, listening]); + await preferences().set(muted, "in_app", "TeammateBanned", false); + + await ban(banned); + + expect((await rows()).map((row) => row.steam_id)).toEqual([listening]); + }); + }); + describe("push-only rows", () => { const rows = async () => postgres.query< @@ -767,6 +838,27 @@ describe("notifications (SQL-driven)", () => { expect(webPush.sendNotification).not.toHaveBeenCalled(); }); + it("pushes banned teammates under their own category, not account", async () => { + const [banned, muted, accountMuted] = await fx.players(3); + for (const steamId of [muted, accountMuted]) { + await subscribe(steamId); + await postgres.query( + `INSERT INTO notifications (type, title, message, role, steam_id, entity_id) + VALUES ('TeammateBanned', 'Player Banned', 'x', 'user', $1::bigint, $2)`, + [steamId, banned], + ); + } + await preferences().set(muted, "push", "teammate_bans", false); + await preferences().set(accountMuted, "push", "account", false); + + await (await configuredService()).sendForBatch("TeammateBanned", banned); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + expect( + (webPush.sendNotification as jest.Mock).mock.calls[0][0].endpoint, + ).toBe(`https://fcm.googleapis.com/fcm/send/${accountMuted}`); + }); + it("holds rather than drops during the recipient's quiet hours", async () => { const steamId = await fx.player(); await subscribe(steamId); diff --git a/test/teammate-bans-preference.spec.ts b/test/teammate-bans-preference.spec.ts new file mode 100644 index 00000000..952a29f0 --- /dev/null +++ b/test/teammate-bans-preference.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Banned-teammate pushes used to ride the account category, so a player who +// had muted account must not start getting them when they split off. +describe("teammate_bans preference migration", () => { + let db: SqlTestDb; + + const up = readFileSync( + join( + __dirname, + "../hasura/migrations/default/1890000000100_teammate_bans_preference/up.sql", + ), + "utf8", + ); + + const MUTED = "76561199500000031"; + const LISTENING = "76561199500000032"; + const CHOSE_AGAIN = "76561199500000033"; + + const teammateBans = async () => + ( + await db.postgres.query>( + `SELECT steam_id::text AS steam_id, enabled + FROM public.notification_preferences + WHERE channel = 'push' AND key = 'teammate_bans' + ORDER BY steam_id`, + ) + ).map(({ steam_id, enabled }) => `${steam_id}:${enabled}`); + + beforeAll(async () => { + db = await bootMigratedDb("TeammateBansPreferenceTest"); + + for (const steamId of [MUTED, LISTENING, CHOSE_AGAIN]) { + await db.postgres.query( + `INSERT INTO public.players (steam_id, name) VALUES ($1, $2) + ON CONFLICT (steam_id) DO NOTHING`, + [steamId, `p${steamId}`], + ); + } + + for (const [steamId, key, enabled] of [ + [MUTED, "account", false], + [MUTED, "news", false], + [LISTENING, "account", true], + [CHOSE_AGAIN, "account", false], + [CHOSE_AGAIN, "teammate_bans", true], + ] as const) { + await db.postgres.query( + `INSERT INTO public.notification_preferences + (steam_id, channel, key, enabled) + VALUES ($1, 'push', $2, $3)`, + [steamId, key, enabled], + ); + } + + await db.postgres.query(up); + await db.postgres.query(up); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("carries a muted account push over, and leaves every other choice alone", async () => { + expect(await teammateBans()).toEqual([ + `${MUTED}:false`, + `${CHOSE_AGAIN}:true`, + ]); + }); +});