Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚫 Create failing test cases for downstream async middleware/listeners #337

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
96 changes: 96 additions & 0 deletions src/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -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]: {
Expand Down Expand Up @@ -101,3 +104,96 @@ export function wrapToResolveOnFirstCall<T extends (...args: any[]) => 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<MessageEvent> = {
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<typeof import('./App').default> {
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(),
},
};
}