diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts deleted file mode 100644 index e3c042291b..0000000000 --- a/src/backend/src/controllers/slack.controllers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Request, Response } from 'express'; -import crypto from 'crypto'; -import slackServices from '../services/slack.services'; - -export default class slackController { - static async handleEvent(req: Request, res: Response) { - console.log('got a slack req'); - if (req.body.type === 'url_verification') { - return res.status(200).send({ challenge: req.body.challenge }); - } - - const slackSignature = req.headers['x-slack-signature'] as string; - const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; - - if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { - return res.status(400).send('Slack request verification failed due to expired timestamp'); - } - - const reqBody = req.body; - - const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; - - const finalSignature = - 'v0=' + - crypto - .createHmac('sha256', process.env.SLACK_BOT_TOKEN ? process.env.SLACK_BOT_TOKEN : '') - .update(signatureBase) - .digest('hex'); - - if ( - crypto.timingSafeEqual( - Uint8Array.from(Buffer.from(finalSignature, 'utf8')), - Uint8Array.from(Buffer.from(slackSignature, 'utf8')) - ) - ) { - slackServices.processEvent(req.body); - return res.status(200).send('Event recieved'); - } - - return res.status(400).send('Slack request verification failed due to incorrect signature'); - } -} diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index c4199a40a8..7be31175f0 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -33,7 +33,7 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async UpdateAnnouncement( + static async updateAnnouncement( text: string, usersReceivedIds: string[], dateCreated: Date, @@ -70,7 +70,7 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async DeleteAnnouncement(slackEventId: string, organizationId: string): Promise { + static async deleteAnnouncement(slackEventId: string, organizationId: string): Promise { const announcement = await prisma.announcement.update({ where: { slackEventId }, data: { diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index d9d028adbf..991af8fc18 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -4,17 +4,23 @@ import { User_Settings } from '@prisma/client'; import AnnouncementService from './announcement.service'; import { Announcement } from 'shared'; +/** + * Represents a slack event for a message in a channel. + */ export interface SlackMessageEvent { type: 'message'; subtype?: string; channel: string; event_ts: string; channel_type: string; + [key: string]: any; } +/** + * Represents a slack message event for a standard sent message. + */ export interface SlackMessage extends SlackMessageEvent { user: string; - type: 'message'; client_msg_id: string; text: string; blocks: { @@ -24,17 +30,27 @@ export interface SlackMessage extends SlackMessageEvent { }[]; } +/** + * Represents a slack message event for a deleted message. + */ export interface SlackDeletedMessage extends SlackMessageEvent { subtype: 'message_deleted'; previous_message: SlackMessage; } +/** + * Represents a slack message event for an edited message. + */ export interface SlackUpdatedMessage extends SlackMessageEvent { subtype: 'message_changed'; message: SlackMessage; previous_message: SlackMessage; } +/** + * Represents a block of information within a message. These blocks with an array + * make up all the information needed to represent the content of a message. + */ export interface SlackRichTextBlock { type: 'broadcast' | 'color' | 'channel' | 'date' | 'emoji' | 'link' | 'text' | 'user' | 'usergroup'; range?: string; @@ -50,6 +66,11 @@ export interface SlackRichTextBlock { } export default class slackServices { + /** + * Converts a SlackRichTextBlock into a string representation for an announcement. + * @param block the block of information from slack + * @returns the string that will be combined with other block's strings to create the announcement + */ private static async blockToString(block: SlackRichTextBlock): Promise { switch (block.type) { case 'broadcast': @@ -57,6 +78,7 @@ export default class slackServices { case 'color': return block.value ?? ''; case 'channel': + //channels are represented as an id, get the name from the slack api let channelName = block.channel_id; try { channelName = await getChannelName(block.channel_id ?? ''); @@ -67,8 +89,10 @@ export default class slackServices { case 'date': return new Date(block.timestamp ?? 0).toISOString(); case 'emoji': + //if the emoji is a unicode emoji, convert the unicode to a string, + //if it is a slack emoji just use the name of the emoji if (block.unicode) { - return String.fromCharCode(parseInt(block.unicode, 16)); + return String.fromCodePoint(parseInt(block.unicode, 16)); } return 'emoji:' + block.name; case 'link': @@ -79,6 +103,7 @@ export default class slackServices { case 'text': return block.text ?? ''; case 'user': + //users are represented as an id, get the name of the user from the slack api let userName: string = block.user_id ?? ''; try { userName = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; @@ -91,6 +116,13 @@ export default class slackServices { } } + /** + * Gets the users notified in a specific SlackRichTextBlock. + * @param block the block that may contain mentioned user/users + * @param usersSettings the settings of all the users in prisma + * @param channelId the id of the channel that the block is being sent in + * @returns an array of prisma user ids of users to be notified + */ private static async blockToMentionedUsers( block: SlackRichTextBlock, usersSettings: User_Settings[], @@ -103,7 +135,11 @@ export default class slackServices { return usersSettings.map((usersSettings) => usersSettings.userId); case 'channel': case 'here': - const slackIds = await getUsersInChannel(channelId); + //@here behaves the same as @channel; notifies all the users in that channel + let slackIds: string[] = []; + try { + slackIds = await getUsersInChannel(channelId); + } catch (ignored) {} return usersSettings .filter((userSettings) => { return slackIds.some((slackId) => slackId === userSettings.slackId); @@ -117,22 +153,38 @@ export default class slackServices { .filter((userSettings) => userSettings.slackId === block.user_id) .map((userSettings) => userSettings.userId); default: + //only broadcasts and specific user mentions add recievers to announcements return []; } } + /** + * Given a slack event representing a message in a channel, + * make the appropriate announcement change in prisma. + * @param event the slack event that will be processed + * @param organizationId the id of the organization represented by the slack api + * @returns an annoucement if an announcement was processed and created/modified/deleted + */ static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { - const slackChannelName = await getChannelName(event.channel); + let slackChannelName: string; + //get the name of the channel from the slack api + try { + slackChannelName = await getChannelName(event.channel); + } catch (error) { + slackChannelName = `Unknown_Channel:${event.channel}`; + } const dateCreated = new Date(Number(event.event_ts)); + //get the message that will be processed either as the event or within a subtype let eventMessage: SlackMessage; if (event.subtype) { switch (event.subtype) { case 'message_deleted': + //delete the message using the client_msg_id eventMessage = (event as SlackDeletedMessage).previous_message; try { - return AnnouncementService.DeleteAnnouncement(eventMessage.client_msg_id, organizationId); + return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); } catch (ignored) { return; } @@ -147,9 +199,12 @@ export default class slackServices { eventMessage = event as SlackMessage; } + //loop through the blocks of the meta data while accumulating the + //text and users notified let messageText = ''; let userIdsToNotify: string[] = []; + //Get the settings of all users in this organization to compare slack ids const users = await UsersService.getAllUsers(); const userSettings = await Promise.all( users.map((user) => { @@ -157,22 +212,28 @@ export default class slackServices { }) ); + //get the name of the user that sent the message from slack let userName: string = ''; try { userName = (await getUserName(eventMessage.user)) ?? ''; } catch (ignored) {} + //if slack could not produce the name of the user, look for their name in prisma if (!userName) { const userIdList = userSettings .filter((userSetting) => userSetting.slackId === eventMessage.user) .map((userSettings) => userSettings.userId); if (userIdList.length !== 0) { - userName = users.find((user) => user.userId === userIdList[0])?.firstName ?? 'Unknown User:' + eventMessage.user; + const prismaUserName = users.find((user) => user.userId === userIdList[0]); + userName = prismaUserName + ? `${prismaUserName?.firstName} ${prismaUserName?.lastName}` + : 'Unknown User:' + eventMessage.user; } else { userName = 'Unknown_User:' + eventMessage.user; } } + //pull out the blocks of data from the metadata within the message event const richTextBlocks = eventMessage.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); if (richTextBlocks && richTextBlocks.length === 1) { @@ -186,9 +247,20 @@ export default class slackServices { return; } + //get rid of duplicates within the users to notify + userIdsToNotify = [...new Set(userIdsToNotify)]; + + //if no users are notified, disregard the message + if (userIdsToNotify.length === 0) { + return; + } + + console.log('processed event'); + if (event.subtype === 'message_changed') { + //try to edit the announcement, if no announcement with that id exists create a new announcement try { - return AnnouncementService.UpdateAnnouncement( + return await AnnouncementService.updateAnnouncement( messageText, userIdsToNotify, dateCreated, @@ -199,14 +271,19 @@ export default class slackServices { ); } catch (ignored) {} } - return AnnouncementService.createAnnouncement( - messageText, - userIdsToNotify, - dateCreated, - userName, - eventMessage.client_msg_id, - slackChannelName, - organizationId - ); + try { + return await AnnouncementService.createAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } catch (error) { + //if announcement does not have unique cient_msg_id disregard it + return; + } } } diff --git a/src/backend/tests/unmocked/organization.test.ts b/src/backend/tests/mocked/organization.test.ts similarity index 100% rename from src/backend/tests/unmocked/organization.test.ts rename to src/backend/tests/mocked/organization.test.ts diff --git a/src/backend/tests/mocked/slackMessages.test.ts b/src/backend/tests/mocked/slackMessages.test.ts new file mode 100644 index 0000000000..f8fc76f4ab --- /dev/null +++ b/src/backend/tests/mocked/slackMessages.test.ts @@ -0,0 +1,464 @@ +import { Organization, User } from '@prisma/client'; +import { createSlackMessageEvent, createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { + batmanAppAdmin, + batmanSettings, + supermanAdmin, + supermanSettings, + wonderwomanGuest, + wonderwomanSettings +} from '../test-data/users.test-data'; +import * as apiFunctions from '../../src/integrations/slack'; +import AnnouncementService from '../../src/services/announcement.service'; +import slackServices from '../../src/services/slack.services'; +import { vi } from 'vitest'; +import { HttpException } from '../../src/utils/errors.utils'; + +vi.mock('../../src/integrations/slack', async (importOriginal) => { + return { + ...(await importOriginal()), + getUserName: vi.fn(), + getChannelName: vi.fn(), + getUsersInChannel: vi.fn() + }; +}); + +describe('Slack message tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let wonderwoman: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); + superman = await createTestUser(supermanAdmin, orgId, supermanSettings); + wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + }); + + afterEach(async () => { + await resetUsers(); + vi.clearAllMocks(); + }); + + it('adds message to everyone with @everyone', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'everyone' }, + { type: 'text', text: ' broadcast (@everyone)' } + ]), + orgId + ); + + console.log(announcement); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @everyone broadcast (@everyone)', + [organization.userCreatedId, batman.userId, superman.userId, wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @everyone broadcast (@everyone)'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(4); + }); + + it('Adds message to people in channel with @channel and @mention (w/o duplicates)', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + vi.mocked(apiFunctions.getUsersInChannel).mockReturnValue(Promise.resolve(['slack', 'slackWW'])); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'channel' }, + { type: 'text', text: ' broadcast (@channel)' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'user', user_id: 'slackSM' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @channel broadcast (@channel)@Slack User Name@Slack User Name', + [batman.userId, wonderwoman.userId, superman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @channel broadcast (@channel)@Slack User Name@Slack User Name'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(3); + }); + + it('Sends the announcement to a single person with a mention', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' broadcast (@wonderwoman)' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @Slack User Name broadcast (@wonderwoman)', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @Slack User Name broadcast (@wonderwoman)'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it('Correctly processes other types of blocks', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with: ' }, + { type: 'link', url: 'http://www.example.com', text: 'link' }, + { type: 'text', text: 'Italics', style: { italic: true } }, + { type: 'text', text: ' and a unicode emoji: ' }, + { + type: 'emoji', + name: 'stuck_out_tongue_closed_eyes', + unicode: '1f61d' + }, + { type: 'text', text: ' and a slack emoji: ' }, + { + type: 'emoji', + name: 'birthday-parrot' + }, + { type: 'user', user_id: 'slackWW' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name' + ); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it('Deals with errors from slack API', async () => { + vi.mocked(apiFunctions.getUserName).mockImplementation(() => { + throw new HttpException(500, 'sample error'); + }); + vi.mocked(apiFunctions.getChannelName).mockImplementation(() => { + throw new HttpException(500, 'sample error'); + }); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'slackWW', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' prisma user and non-prisma user ' }, + { type: 'user', user_id: 'non-prisma-slack-id' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id', + [wonderwoman.userId], + new Date(1000000000000), + 'Wonder Woman', + 'id_1', + 'Unknown_Channel:channel id', + orgId + ); + + expect(announcement?.text).toBe( + '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id' + ); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Wonder Woman'); + expect(announcement?.slackChannelName).toBe('Unknown_Channel:channel id'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it("Doesn't create an announcement if no one is mentioned", async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'just a text message' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(0); + + expect(announcement).toBeUndefined(); + }); + + it('Does nothing if an announcement with the same slack id has already been created', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + orgId + ); + expect(announcement2).toBeUndefined(); + }); + + it('Updates an edit made to a message', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates a new announcement if the announcement to update is not found', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(createSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates and deletes and announcement', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + expect(createSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledWith('id_1', orgId); + }); + + it('Does nothing if recieves other message subtype', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'other-nonprocessed-subtype', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + bogus_data: 'other data' + }, + orgId + ); + expect(createSpy).toBeCalledTimes(0); + expect(announcement).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 99f2a010e2..fc6698ff06 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -18,6 +18,7 @@ import { getWorkPackageTemplateQueryArgs } from '../src/prisma-query-args/work-p import DesignReviewsService from '../src/services/design-reviews.services'; import TasksService from '../src/services/tasks.services'; import ProjectsService from '../src/services/projects.services'; +import { SlackMessage } from '../src/services/slack.services'; export interface CreateTestUserParams { firstName: string; @@ -119,6 +120,7 @@ export const resetUsers = async () => { await prisma.milestone.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.organization.deleteMany(); + await prisma.announcement.deleteMany(); await prisma.user.deleteMany(); }; @@ -454,3 +456,33 @@ export const createTestTask = async (user: User, organization?: Organization) => if (!task) throw new Error('Failed to create task'); return { task, organization, orgId }; }; + +export const createSlackMessageEvent = ( + channel: string, + event_ts: string, + user: string, + client_msg_id: string, + elements: any[] +): SlackMessage => { + return { + type: 'message', + channel, + event_ts, + channel_type: 'channel', + user, + client_msg_id, + text: 'sample text', + blocks: [ + { + type: 'rich_text', + block_id: 'block id', + elements: [ + { + type: 'rich_text_section', + elements + } + ] + } + ] + }; +};