diff --git a/package.json b/package.json index 90cc4700f..6035bdb91 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "mocha": "^6.1.4", "nyc": "^14.0.0", "rewiremock": "^3.13.4", + "serverless-http": "^2.3.0", "shx": "^0.3.2", "sinon": "^7.3.1", "source-map-support": "^0.5.12", diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 000000000..9f0577165 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,91 @@ +// tslint:disable:no-implicit-dependencies +import 'mocha'; +import { assert } from 'chai'; +import { App, ExpressReceiver } from './index'; +import serverlessHttp from 'serverless-http'; +import { + delay, + createMessageEventRequest, + importAppWithMockSlackClient, +} from './test-helpers'; + +describe('When being used in testing downstream', () => { + let app: App; + let handler: any; + let request: any; + const message = 'Hey there!'; + + beforeEach(async () => { + const receiver = new ExpressReceiver({ signingSecret: 'SECRET' }); + const RewiredApp = await importAppWithMockSlackClient(); + + app = new RewiredApp({ receiver, token: '' }); + + // Undecided on best wrapper - See discussion here https://community.slack.com/archives/CHY642221/p1575577886047900 + // This wrapper should take an event and return a promise with a response when its event loop has completed + handler = serverlessHttp(receiver.app); + + // example slack event request information to be sent via handler in tests + request = createMessageEventRequest(message); + }); + + it('correctly waits for async listeners', async () => { + let changed = false; + + app.message(message, async ({ next }) => { + await delay(100); + changed = true; + + next(); + }); + + const response = await handler(request); + + assert.equal(response.statusCode, 200); + assert.isTrue(changed); // Actual `false`, even though changed to `true` in async listener + }); + + it('throws errors which can be caught by downstream async listeners', async () => { + app.message('Hey', async ({ next }) => { + const error = new Error('Error handling the message :('); + + next(error); // likely that most 'async' middleware wouldn't do this, but probably should work? + + throw error; // Nothing catches this up the stack, but this is what async middleware is likely doing + }); + + app.error(() => { + // Never called; middleware should handle its own errors, but a handler can be helpful unexpected errors. + }); + + const response = await handler(request); + + assert.equal(response.statusCode, 500); // Actual 200, even though error was thrown + }); + + it('calls async middleware in declared order', async () => { + let middlewareCount = 0; + + const assertOrderMiddleware = (order: number) => async ({ next }: any) => { + await delay(100); + middlewareCount += 1; + assert.equal(middlewareCount, order); + next(); + }; + + app.use(assertOrderMiddleware(1)); + + app.message(message, assertOrderMiddleware(2), assertOrderMiddleware(3)); + + // This middleware is never called; if it detects a message as 'last' it gives a noop instead of a real callback. + // Discovered this by trying to polyfill bolt sticking a handler here to possibly find when the event loop was done. + // A real use case would be having a message set a `state` in its context and a handler here saving it to a db + app.use(assertOrderMiddleware(4)); + + await handler(request); + + await delay(600); // This should be removable; without it none of the middleware is called + + assert.equal(middlewareCount, 4); // Actual 3, 4th never called + }); +}); diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 6a1029bf3..a73d1fdfd 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -1,6 +1,9 @@ // tslint:disable:no-implicit-dependencies import sinon, { SinonSpy } from 'sinon'; import { Logger } from '@slack/logger'; +import crypto from 'crypto'; +import { MessageEvent } from './types'; +import rewiremock from 'rewiremock'; export interface Override { [packageName: string]: { @@ -101,3 +104,96 @@ export function wrapToResolveOnFirstCall void>( fn: wrapped, }; } + +// Below functions used to help ensure downstream apps consuming the package can effectively test + +export interface ServerlessEvent { + body: string; + headers: { [key: string]: string }; + httpMethod: string; + path: string; +} + +const createRequest = (data: any): ServerlessEvent => { + const body = JSON.stringify(data); + const version = 'v0'; + const timestamp = Math.floor(Date.now() / 1000); + const hmac = crypto.createHmac('sha256', 'SECRET'); + + hmac.update(`${version}:${timestamp}:${body}`); + + return { + body, + headers: { + 'content-type': 'application/json', + 'x-slack-request-timestamp': timestamp.toString(), + 'x-slack-signature': `${version}=${hmac.digest('hex')}`, + }, + httpMethod: 'POST', + path: '/slack/events', + }; +}; + +export const createFakeMessageEvent = ( + content: string | MessageEvent['blocks'] = '', +): MessageEvent => { + const event: Partial = { + type: 'message', + channel: 'CHANNEL_ID', + ts: 'MESSAGE_ID', + user: 'USER_ID', + }; + + if (typeof content === 'string') { + event.text = content; + } else { + event.blocks = content; + } + + return event as MessageEvent; +}; + +export const createEventRequest = (event: MessageEvent): ServerlessEvent => + createRequest({ event }); + +export const createMessageEventRequest = (message: string): ServerlessEvent => + createRequest({ event: createFakeMessageEvent(message) }); + +export async function importAppWithMockSlackClient( + overrides: Override = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ), +): Promise { + return (await rewiremock.module(() => import('./App'), overrides)).default; +} + +// Composable overrides +function withNoopWebClient(): Override { + return { + '@slack/web-api': { + WebClient: class { + public auth = { + test: sinon.fake.resolves({ user_id: 'BOT_USER_ID' }), + }; + public users = { + info: sinon.fake.resolves({ + user: { + profile: { + bot_id: 'BOT_ID', + }, + }, + }), + }; + }, + }, + }; +} + +function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +}