diff --git a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql new file mode 100644 index 00000000..f0849ce7 --- /dev/null +++ b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql @@ -0,0 +1,4 @@ +UPDATE public.notifications + SET type = 'ChatMessage' + WHERE type = 'MatchChatMessage' + AND entity_id LIKE 'match\_team:%' ESCAPE '\'; diff --git a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql new file mode 100644 index 00000000..6e2627c1 --- /dev/null +++ b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql @@ -0,0 +1,8 @@ +-- Team rooms now notify as MatchChatMessage (ChatService.notificationTypeFor), +-- and the read-clear and bell collapse look rows up by that type, so a row left +-- on the old one would never clear when its room is read. `_` is a LIKE +-- wildcard, hence the escape. +UPDATE public.notifications + SET type = 'MatchChatMessage' + WHERE type = 'ChatMessage' + AND entity_id LIKE 'match\_team:%' ESCAPE '\'; diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 30a28cc9..751f296c 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -361,6 +361,19 @@ describe("ChatService direct messages", () => { : {}; } + if (query.match_lineups_by_pk) { + return query.match_lineups_by_pk.__args.id === "l-1" + ? { + match_lineups_by_pk: { + match_id: "m-1", + coach_steam_id: null, + is_on_lineup: true, + lineup_players: [{ steam_id: ME }, { steam_id: FRIEND }], + }, + } + : {}; + } + if (query.players) { return { players: staff.map((steam_id) => ({ steam_id })) }; } @@ -691,6 +704,107 @@ describe("ChatService direct messages", () => { }); }); + describe("notification type", () => { + it.each([ChatLobbyType.Match, ChatLobbyType.MatchTeam])( + "files %s chat under match chat", + (type) => { + expect(ChatService.notificationTypeFor(type)).toBe("MatchChatMessage"); + }, + ); + + it.each([ + ChatLobbyType.Direct, + ChatLobbyType.Tournament, + ChatLobbyType.MatchMaking, + ChatLobbyType.Draft, + ChatLobbyType.Organizer, + ])("files %s chat as a plain chat message", (type) => { + expect(ChatService.notificationTypeFor(type)).toBe("ChatMessage"); + }); + + it("notifies and collapses a team room line under one type", async () => { + await service.sendMessageToChat( + ChatLobbyType.MatchTeam, + "m-1:l-1", + { steam_id: ME, name: "Someone", role } as any, + "rotate b", + true, + "game", + ); + + for (let tick = 0; tick < 5; tick++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(notifications.notifyPlayers).toHaveBeenCalledWith( + "MatchChatMessage", + expect.objectContaining({ + entity_id: "match_team:m-1:l-1", + steamIds: [FRIEND], + }), + ); + expect(notifications.collapseOlderUnread).toHaveBeenCalledWith( + "MatchChatMessage", + "match_team:m-1:l-1", + [FRIEND], + ); + }); + + it("clears a team room's badge under the type it was sent with", async () => { + await service.markThreadRead(ChatLobbyType.MatchTeam, "m-1:l-1", { + steam_id: ME, + } as any); + + expect(notifications.markConversationRead).toHaveBeenCalledWith( + "MatchChatMessage", + "match_team:m-1:l-1", + ME, + ); + }); + }); + + describe("live delivery", () => { + const present = (key: string, steamIds: string[]) => + redis.hgetall.mockImplementation(async (hash: string) => + hash === key + ? Object.fromEntries( + steamIds.map((steamId) => [ + steamId, + JSON.stringify({ user: { steam_id: steamId } }), + ]), + ) + : {}, + ); + + const deliveredTo = () => + redis.publish.mock.calls + .map(([, payload]) => JSON.parse(payload)) + .filter(({ event }) => event.endsWith(":chat")) + .map(({ steamId }) => steamId); + + afterEach(() => { + redis.hgetall.mockResolvedValue({}); + }); + + it("keeps a team room's lines from someone no longer on the lineup", async () => { + // still present from when they were on it: presence is only cleared on + // leave, and outlives a move to the other lineup + present("chat:match_team:m-1:l-1", [ME, FRIEND, STRANGER]); + + await service.to(ChatLobbyType.MatchTeam, "m-1:l-1", "chat", {}, ME); + + expect(deliveredTo()).toEqual([ME, FRIEND]); + }); + + it("still reaches everyone present in a match room", async () => { + present("chat:match:m-1", [ME, FRIEND, STRANGER]); + + await service.to(ChatLobbyType.Match, "m-1", "chat", {}, ME); + + expect(deliveredTo()).toEqual([ME, FRIEND, STRANGER]); + }); + }); + describe("message text", () => { it.each([ ["a number", 42], diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 2eeda4f1..ef9f4f10 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1784,18 +1784,21 @@ export class ChatService { // Match chat is its own notification type, and so its own push category. // - // Every line typed in-game is relayed into the match room by - // ChatMessageEvent, so a live match fires this per lineup member per line -- - // and the player it reaches is the one already reading those lines in the - // game. Sharing a category with direct messages meant the only way to stop - // that was to mute DMs too. + // Every line typed in-game is relayed -- all chat into the match room by + // ChatMessageEvent, team chat into the lineup's team room by + // TeamChatMessageEvent -- so a live match fires this per lineup member per + // line, and the player it reaches is the one already reading those lines in + // the game. Sharing a category with direct messages meant the only way to + // stop that was to mute DMs too. // // The insert, the bell collapse and the read-clear all have to agree on the // type or the collapse stops collapsing and the badge never clears. public static notificationTypeFor( type: ChatLobbyType, ): e_notification_types_enum { - return type === ChatLobbyType.Match ? "MatchChatMessage" : "ChatMessage"; + return type === ChatLobbyType.Match || type === ChatLobbyType.MatchTeam + ? "MatchChatMessage" + : "ChatMessage"; } // What to call this room when a push has to name it -- "3 new messages from @@ -2537,6 +2540,7 @@ export class ChatService { ): Promise { const users = await this.getAllUsersInLobby(type, id); const eventName = `lobby:${type}:${id}:${event}`; + const roster = await this.teamRoomRoster(type, id); const hiding = author === undefined @@ -2547,6 +2551,10 @@ export class ChatService { ); for (const { steamId } of users) { + if (roster && !roster.has(String(steamId))) { + continue; + } + if (hiding.has(steamId)) { continue; } @@ -2585,6 +2593,21 @@ export class ChatService { ); } + // Presence outlives membership (see canPostIn), and a team room is the one + // whose lines the rest of the match must never see: a player moved to the + // other lineup, or benched, would otherwise keep receiving them until their + // presence expired. + private async teamRoomRoster( + type: ChatLobbyType, + id: string, + ): Promise | null> { + if (type !== ChatLobbyType.MatchTeam) { + return null; + } + + return new Set(await this.getLobbyMemberSteamIds(type, id)); + } + public async removeFromLobby( type: ChatLobbyType, id: string, diff --git a/src/matches/events/ChatMessageEvent.spec.ts b/src/matches/events/ChatMessageEvent.spec.ts new file mode 100644 index 00000000..d6e26a0d --- /dev/null +++ b/src/matches/events/ChatMessageEvent.spec.ts @@ -0,0 +1,100 @@ +import { Logger } from "@nestjs/common"; +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import ChatMessageEvent from "./ChatMessageEvent"; + +const MATCH_ID = "11111111-1111-1111-1111-111111111111"; +const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; +const SPEAKER = "76561198000000001"; + +describe("ChatMessageEvent", () => { + let processor: ChatMessageEvent; + let hasura: { query: jest.Mock }; + let chat: { sendMessageToChat: jest.Mock }; + let logger: Logger; + let speaker: Record | null; + + beforeEach(() => { + speaker = { + name: "keith", + role: "user", + steam_id: SPEAKER, + profile_url: null, + avatar_url: null, + discord_id: null, + }; + + hasura = { + query: jest.fn(async (query: Record) => { + if (query.players_by_pk) { + return { players_by_pk: speaker }; + } + + throw new Error("unexpected query"); + }), + }; + chat = { sendMessageToChat: jest.fn(async () => ({ accepted: true })) }; + logger = new Logger("ChatMessageEventTest"); + jest.spyOn(logger, "warn").mockImplementation(() => undefined); + + processor = new ChatMessageEvent( + logger, + hasura as any, + {} as any, + chat as any, + {} as any, + ); + }); + + const send = async (data: Record) => { + processor.setData(MATCH_ID, { + player: SPEAKER, + message: "gl hf", + ...data, + } as any); + + await processor.process(); + }; + + it("relays all chat into the match room", async () => { + await send({}); + + expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.Match, + MATCH_ID, + expect.objectContaining({ steam_id: SPEAKER }), + "gl hf", + true, + "game", + ); + }); + + it("drops a line from a player it does not know", async () => { + speaker = null; + + await send({}); + + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + }); + + // team lines have their own event; one on this event is from a plugin on + // the contract that sent them as chat, and must not reach the other team + describe("drops team fields instead of posting them to the match room", () => { + it.each([ + ["a team line", { teamOnly: true, lineupId: LINEUP_ID }], + ["a lone team flag", { teamOnly: true }], + ["a lone lineup", { lineupId: LINEUP_ID }], + ["a false team flag", { teamOnly: false }], + ["a null lineup", { lineupId: null }], + ])("%s", async (_, data) => { + await send(data); + + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + expect(hasura.query).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("dropping chat"), + expect.anything(), + ); + }); + }); +}); diff --git a/src/matches/events/ChatMessageEvent.ts b/src/matches/events/ChatMessageEvent.ts index d0f21764..c8c8e3ed 100644 --- a/src/matches/events/ChatMessageEvent.ts +++ b/src/matches/events/ChatMessageEvent.ts @@ -4,8 +4,16 @@ import MatchEventProcessor from "./abstracts/MatchEventProcessor"; export default class ChatMessageEvent extends MatchEventProcessor<{ player: string; message: string; + teamOnly?: unknown; + lineupId?: unknown; }> { public async process() { + const room = await this.room(); + + if (!room) { + return; + } + const { players_by_pk } = await this.hasura.query({ players_by_pk: { __args: { @@ -26,12 +34,29 @@ export default class ChatMessageEvent extends MatchEventProcessor<{ } await this.chat.sendMessageToChat( - ChatLobbyType.Match, - this.matchId, + room.type, + room.id, players_by_pk, this.data.message, true, "game", ); } + + // Team lines travel as teamChat so an api that predates them drops them as + // an unknown event. One that still carries team fields here comes from a + // plugin on the old contract, and the match room holds the other team too. + protected async room(): Promise<{ type: ChatLobbyType; id: string } | null> { + const { teamOnly, lineupId, player } = this.data; + + if (teamOnly !== undefined || lineupId !== undefined) { + this.logger.warn( + `[${this.matchId}] dropping chat from ${player}: team fields on the all chat event`, + { teamOnly, lineupId }, + ); + return null; + } + + return { type: ChatLobbyType.Match, id: this.matchId }; + } } diff --git a/src/matches/events/TeamChatMessageEvent.spec.ts b/src/matches/events/TeamChatMessageEvent.spec.ts new file mode 100644 index 00000000..d825bb6f --- /dev/null +++ b/src/matches/events/TeamChatMessageEvent.spec.ts @@ -0,0 +1,219 @@ +import { Logger } from "@nestjs/common"; +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; + +const MATCH_ID = "11111111-1111-1111-1111-111111111111"; +const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_MATCH_ID = "33333333-3333-3333-3333-333333333333"; +const SPEAKER = "76561198000000001"; +const COACH = "76561198000000002"; +const TEAMMATE = "76561198000000003"; + +describe("TeamChatMessageEvent", () => { + let processor: TeamChatMessageEvent; + let hasura: { query: jest.Mock }; + let chat: { sendMessageToChat: jest.Mock }; + let known: boolean; + let lineup: { + id: string; + match_id: string; + coach_steam_id: string | null; + lineup_players: Array<{ steam_id: string }>; + } | null; + + beforeEach(() => { + known = true; + lineup = { + id: LINEUP_ID, + match_id: MATCH_ID, + coach_steam_id: COACH, + lineup_players: [{ steam_id: SPEAKER }, { steam_id: TEAMMATE }], + }; + + hasura = { + query: jest.fn(async (query: Record) => { + if (query.players_by_pk) { + return { + players_by_pk: known + ? { + name: "keith", + role: "user", + steam_id: query.players_by_pk.__args.steam_id, + profile_url: null as string | null, + avatar_url: null as string | null, + discord_id: null as string | null, + } + : null, + }; + } + + if (query.match_lineups_by_pk) { + const speakerId = + query.match_lineups_by_pk.lineup_players.__args.where.steam_id._eq; + + return { + match_lineups_by_pk: lineup && { + ...lineup, + lineup_players: lineup.lineup_players.filter( + ({ steam_id }) => steam_id === speakerId, + ), + }, + }; + } + + throw new Error("unexpected query"); + }), + }; + chat = { sendMessageToChat: jest.fn(async () => ({ accepted: true })) }; + + const logger = new Logger("TeamChatMessageEventTest"); + jest.spyOn(logger, "warn").mockImplementation(() => undefined); + + processor = new TeamChatMessageEvent( + logger, + hasura as any, + {} as any, + chat as any, + {} as any, + ); + }); + + const send = async (data: Record) => { + processor.setData(MATCH_ID, { + player: SPEAKER, + message: "rotate b", + ...data, + } as any); + + await processor.process(); + }; + + const lineupQuery = () => + hasura.query.mock.calls + .map(([query]) => query.match_lineups_by_pk) + .find(Boolean); + + it("relays team chat into the speaker's team room", async () => { + await send({ lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.objectContaining({ steam_id: SPEAKER }), + "rotate b", + true, + "game", + ); + }); + + it("checks the speaker against the lineup the plugin named", async () => { + await send({ lineupId: LINEUP_ID }); + + expect(lineupQuery().__args).toEqual({ id: LINEUP_ID }); + expect(lineupQuery().lineup_players.__args).toEqual({ + where: { steam_id: { _eq: SPEAKER } }, + }); + }); + + it("lets the lineup's coach speak in its team room", async () => { + await send({ player: COACH, message: "timeout", lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.objectContaining({ steam_id: COACH }), + "timeout", + true, + "game", + ); + }); + + it("keys the room on the lineup as stored, whatever case the plugin sent", async () => { + await send({ lineupId: LINEUP_ID.toUpperCase() }); + + expect(chat.sendMessageToChat.mock.calls[0][1]).toBe( + `${MATCH_ID}:${LINEUP_ID}`, + ); + }); + + describe("never falls back to the match room", () => { + const expectDropped = () => { + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + }; + + it("drops team chat without a lineup", async () => { + await send({}); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup id that is not a uuid", async () => { + await send({ lineupId: "lineup-1" }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup id that is not a string", async () => { + await send({ lineupId: 42 }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["not a string", 76561198000000001], + ["not a steam id", "keith"], + ])("drops a speaker that is %s", async (_, player) => { + await send({ player, lineupId: LINEUP_ID }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup that does not exist", async () => { + lineup = null; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a lineup from another match", async () => { + lineup!.match_id = OTHER_MATCH_ID; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker who is not on the lineup", async () => { + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker when the lineup has no coach", async () => { + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; + lineup!.coach_steam_id = null; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a line from a player it does not know", async () => { + known = false; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + }); +}); diff --git a/src/matches/events/TeamChatMessageEvent.ts b/src/matches/events/TeamChatMessageEvent.ts new file mode 100644 index 00000000..838a1337 --- /dev/null +++ b/src/matches/events/TeamChatMessageEvent.ts @@ -0,0 +1,76 @@ +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import ChatMessageEvent from "./ChatMessageEvent"; + +export default class TeamChatMessageEvent extends ChatMessageEvent { + private static readonly UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + protected async room(): Promise<{ type: ChatLobbyType; id: string } | null> { + const { lineupId, player } = this.data; + + // a missing speaker would leave the roster filter below as + // `steam_id: {}`, which matches every player on the lineup + if (typeof player !== "string" || !/^\d+$/.test(player)) { + this.logger.warn( + `[${this.matchId}] dropping team chat: malformed player`, + { player }, + ); + return null; + } + + if ( + typeof lineupId !== "string" || + !TeamChatMessageEvent.UUID.test(lineupId) + ) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: malformed lineup`, + { lineupId }, + ); + return null; + } + + const { match_lineups_by_pk } = await this.hasura.query({ + match_lineups_by_pk: { + __args: { + id: lineupId, + }, + id: true, + match_id: true, + coach_steam_id: true, + lineup_players: { + __args: { + where: { + steam_id: { + _eq: player, + }, + }, + }, + steam_id: true, + }, + }, + }); + + if (!match_lineups_by_pk || match_lineups_by_pk.match_id !== this.matchId) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: lineup ${lineupId} is not part of this match`, + ); + return null; + } + + const isCoach = + match_lineups_by_pk.coach_steam_id != null && + String(match_lineups_by_pk.coach_steam_id) === String(player); + + if (match_lineups_by_pk.lineup_players.length === 0 && !isCoach) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: not on lineup ${lineupId}`, + ); + return null; + } + + return { + type: ChatLobbyType.MatchTeam, + id: `${match_lineups_by_pk.match_id}:${match_lineups_by_pk.id}`, + }; + } +} diff --git a/src/matches/events/index.spec.ts b/src/matches/events/index.spec.ts new file mode 100644 index 00000000..54686898 --- /dev/null +++ b/src/matches/events/index.spec.ts @@ -0,0 +1,12 @@ +import { MatchEvents } from "./index"; +import ChatMessageEvent from "./ChatMessageEvent"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; + +describe("MatchEvents", () => { + // the plugin sends team lines under their own name so that an api without + // a handler for it drops them rather than posting them as all chat + it("routes team chat and all chat to separate handlers", () => { + expect(MatchEvents.chat).toBe(ChatMessageEvent); + expect(MatchEvents.teamChat).toBe(TeamChatMessageEvent); + }); +}); diff --git a/src/matches/events/index.ts b/src/matches/events/index.ts index aece0351..c717c888 100644 --- a/src/matches/events/index.ts +++ b/src/matches/events/index.ts @@ -16,6 +16,7 @@ import FlashEvent from "./FlashEvent"; import ObjectiveEvent from "./ObjectiveEvent"; import UnusedUtility from "./UnusedUtility"; import ChatMessageEvent from "./ChatMessageEvent"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; import MatchSurrendered from "./MatchSurrendered"; import MatchAbandoned from "./MatchAbandoned"; @@ -26,6 +27,7 @@ export const MatchEvents = { updateLineups: MatchUpdatedLineupsEvent, chat: ChatMessageEvent, + teamChat: TeamChatMessageEvent, /** * Player diff --git a/src/matches/match-events.gateway.spec.ts b/src/matches/match-events.gateway.spec.ts index ac2fecca..6080ec30 100644 --- a/src/matches/match-events.gateway.spec.ts +++ b/src/matches/match-events.gateway.spec.ts @@ -210,6 +210,27 @@ describe("MatchEventsGateway.handleMatchEvent dedup", () => { expect(store.has(`match-events:${MATCH_1}:msg-1`)).toBe(false); }); + it("acks and drops an event it has no handler for", async () => { + // how an api that predates teamChat drops team lines: not processed, + // and acked so the plugin stops retrying + const { gateway, cache, processor, logger } = makeGateway(); + const result = await gateway.handleMatchEvent( + authedSocket(), + event({ name: "notAnEvent", data: { player: "p", message: "m" } }), + ); + + expect(result).toBe("msg-1"); + expect(processor.setData).not.toHaveBeenCalled(); + expect(processor.process).not.toHaveBeenCalled(); + expect(cache.put.mock.calls.some(([key]) => key.endsWith(":msg-1"))).toBe( + false, + ); + expect(logger.warn).toHaveBeenCalledWith( + "unable to find event handler", + "notAnEvent", + ); + }); + it("short-circuits on a dedup hit without processing", async () => { const { gateway, processor } = makeGateway({ cacheHit: true }); const result = await gateway.handleMatchEvent(authedSocket(), event()); diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index 4ae94785..ba6b39fb 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -7,6 +7,22 @@ jest.mock("@kubernetes/client-node", () => ({ import { MatchesController } from "./matches.controller"; +// Positional, in constructor order; anything a test does not reach stays {}. +const controllerWith = (deps: { + hasura?: unknown; + matchAssistant?: unknown; + gameModes?: unknown; +}) => { + const args: any[] = Array.from({ length: 30 }, () => ({})); + + args[1] = deps.hasura ?? {}; + args[3] = { get: jest.fn(() => ({})) }; + args[5] = deps.matchAssistant ?? {}; + args[29] = deps.gameModes ?? {}; + + return new (MatchesController as any)(...args) as MatchesController; +}; + describe("MatchesController", () => { let controller: MatchesController; let matchAssistant: { @@ -174,4 +190,71 @@ describe("MatchesController", () => { expect(notifications.send).not.toHaveBeenCalled(); }); }); + + describe("current match", () => { + const lineup = (id: string) => ({ + id, + name: id, + team: null as null, + coach_steam_id: null as null, + lineup_players: [] as unknown[], + }); + + const payload = async () => { + const hasura = { + query: jest.fn(async (query: Record) => { + if (query.servers_by_pk) { + return { servers_by_pk: { current_match: { id: "match-1" } } }; + } + + if (query.matches_by_pk) { + return { + matches_by_pk: { + id: "match-1", + status: "Live", + is_tournament_match: false, + draft_games: [] as unknown[], + server: { server_region: { is_lan: false } }, + options: { type: "Competitive", game_mode_id: null as null }, + match_maps: [] as unknown[], + lineup_1: lineup("lineup-1"), + lineup_2: lineup("lineup-2"), + tournament_brackets: [] as unknown[], + }, + }; + } + + if (query.match_type_cfgs) { + return { match_type_cfgs: [] as unknown[] }; + } + + return { settings_by_pk: null as null }; + }), + }; + + const json = jest.fn(); + const response = { status: jest.fn(() => ({ json })) }; + + await controllerWith({ + hasura, + gameModes: { + resolveForServer: jest.fn(async (): Promise => null), + pluginCfgLayers: jest.fn(async () => [] as unknown[]), + }, + }).getMatchDetails( + { params: { serverId: "server-1" }, headers: {} } as any, + response as any, + ); + + expect(response.status).toHaveBeenCalledWith(200); + + return json.mock.calls[0][0]; + }; + + it("advertises team chat relay as a JSON boolean", async () => { + // the plugin only relays say_team when this is present, and its + // deserializer rejects the whole payload if it arrives as a string + expect((await payload()).relay_team_chat).toBe(true); + }); + }); }); diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 8896a78c..8fc37404 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -356,6 +356,7 @@ export class MatchesController { const match = matches_by_pk as typeof matches_by_pk & { is_lan: boolean; is_draft_match: boolean; + relay_team_chat: boolean; options: typeof matches_by_pk.options & { use_playcast: boolean; show_elo_ranks: boolean; @@ -396,6 +397,11 @@ export class MatchesController { match.is_draft_match = match.draft_games.length > 0; delete match.draft_games; + // The plugin relays say_team only when this is present, so an api that + // cannot route team chat never receives any. A JSON boolean: the plugin's + // deserializer rejects the whole payload over a string. + match.relay_team_chat = true; + const fivestackRanksSettingName = match.is_tournament_match ? "fivestack_ranks_tournaments" : "fivestack_ranks_matches"; diff --git a/test/match-team-chat-notification-type.spec.ts b/test/match-team-chat-notification-type.spec.ts new file mode 100644 index 00000000..8a3bdb31 --- /dev/null +++ b/test/match-team-chat-notification-type.spec.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Team rooms moved from ChatMessage to MatchChatMessage. The read-clear and the +// bell collapse look rows up by the new type, so any team row left on the old +// one would never clear when its room is read. +describe("match team chat notification type migration", () => { + let db: SqlTestDb; + + const migration = join( + __dirname, + "../hasura/migrations/default/1888000000400_match_team_chat_notification_type", + ); + const up = readFileSync(join(migration, "up.sql"), "utf8"); + const down = readFileSync(join(migration, "down.sql"), "utf8"); + + const STEAM_ID = "76561199500000011"; + + const typeOf = async (entityId: string) => + ( + await db.postgres.query>( + `SELECT type FROM public.notifications WHERE entity_id = $1`, + [entityId], + ) + ).at(0)?.type; + + beforeAll(async () => { + db = await bootMigratedDb("MatchTeamChatNotificationTypeTest"); + + await db.postgres.query( + `INSERT INTO public.players (steam_id, name) VALUES ($1, 'Retyped') + ON CONFLICT (steam_id) DO NOTHING`, + [STEAM_ID], + ); + + for (const entity of [ + "match_team:m-9:l-1", + "match:m-9", + "direct:1:2", + "matchXteam:m-9:l-1", + ]) { + await db.postgres.query( + `INSERT INTO public.notifications + (type, title, message, role, steam_id, entity_id) + VALUES ('ChatMessage', 'Luke', 'hey', 'user', $1, $2)`, + [STEAM_ID, entity], + ); + } + + await db.postgres.query(up); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("moves a team room's rows to match chat", async () => { + expect(await typeOf("match_team:m-9:l-1")).toBe("MatchChatMessage"); + }); + + it("leaves rows that are not team rooms alone", async () => { + expect(await typeOf("match:m-9")).toBe("ChatMessage"); + expect(await typeOf("direct:1:2")).toBe("ChatMessage"); + }); + + it("does not treat the underscore as a wildcard", async () => { + expect(await typeOf("matchXteam:m-9:l-1")).toBe("ChatMessage"); + }); + + it("puts only the team rows back on the way down", async () => { + await db.postgres.query( + `UPDATE public.notifications SET type = 'MatchChatMessage' + WHERE entity_id = 'match:m-9'`, + ); + + await db.postgres.query(down); + + expect(await typeOf("match_team:m-9:l-1")).toBe("ChatMessage"); + expect(await typeOf("match:m-9")).toBe("MatchChatMessage"); + + await db.postgres.query(up); + }); +});