Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .mocharc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"require": ["ts-node/register", "source-map-support/register"]
"require": ["ts-node/register", "source-map-support/register"],
"timeout": 3000
}
2 changes: 1 addition & 1 deletion .nycrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"src/**/*.ts"
],
"exclude": [
"**/*.spec.js"
"**/*.spec.ts"
],
"reporter": ["lcov"],
"extension": [
Expand Down
27 changes: 27 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Spec tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
"stopOnEntry": false,
"args": [
"--config",
".mocharc.json",
"--no-timeouts",
"src/*.spec.ts",
"src/**/*.spec.ts"
],
"cwd": "${workspaceFolder}",
"runtimeExecutable": null,
"env": {
"NODE_ENV": "testing"
}
}
]
}
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"build": "tsc",
"build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output",
"lint": "tslint --project .",
"test": "npm run build && nyc mocha --config .mocharc.json src/*.spec.js",
"test": "nyc mocha --config .mocharc.json src/*.spec.ts",
"coverage": "codecov"
},
"repository": "slackapi/bolt",
Expand All @@ -49,10 +49,18 @@
"tsscmp": "^1.0.6"
},
"devDependencies": {
"@types/chai": "^4.1.7",
"@types/mocha": "^5.2.6",
"@types/sinon": "^7.0.11",
"chai": "^4.2.0",
"codecov": "^3.2.0",
"mocha": "^6.1.4",
"nyc": "^14.0.0",
"rewiremock": "^3.13.4",
"shx": "^0.3.2",
"sinon": "^7.3.1",
"source-map-support": "^0.5.12",
"ts-node": "^8.1.0",
"tslint": "^5.15.0",
"tslint-config-airbnb": "^5.11.1",
"typescript": "^3.4.3"
Expand Down
291 changes: 291 additions & 0 deletions src/App.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// tslint:disable:ter-prefer-arrow-callback typedef no-implicit-dependencies no-this-assignment
import 'mocha';
import { EventEmitter } from 'events';
import sinon, { SinonSpy } from 'sinon';
import { assert } from 'chai';
import rewiremock from 'rewiremock';
import { ErrorCode } from './errors';
import { Receiver, ReceiverEvent, Middleware, AnyMiddlewareArgs } from './types';
import { ConversationStore } from './conversation-store';
import { Logger } from '@slack/logger';

describe('App', () => {
describe('constructor', () => {
// TODO: test when the single team authorization results fail. that should still succeed but warn. it also means
// that the `ignoreSelf` middleware will fail (or maybe just warn) a bunch.
describe('with successful single team authorization results', () => {
it('should succeed with a token for single team authorization', async () => {
const { App } = await importAppWhichFetchesOwnBotIds();
new App({ token: '', signingSecret: '' }); // tslint:disable-line:no-unused-expression
});
});
it('should succeed with an authorize callback', async () => {
const { App } = await importApp();
const authorizeCallback = sinon.spy();
new App({ authorize: authorizeCallback, signingSecret: '' }); // tslint:disable-line:no-unused-expression
assert(authorizeCallback.notCalled);
});
it('should fail without a token for single team authorization or authorize callback', async () => {
const { App } = await importApp();
try {
new App({ signingSecret: '' }); // tslint:disable-line:no-unused-expression
assert.fail();
} catch (error) {
assert.instanceOf(error, Error);
assert.propertyVal(error, 'code', ErrorCode.AppInitializationError);
}
});
it('should fail when both a token and authorize callback are specified', async () => {
const { App } = await importApp();
const authorizeCallback = sinon.spy();
try {
// tslint:disable-next-line:no-unused-expression
new App({ token: '', authorize: authorizeCallback, signingSecret: '' });
assert.fail();
} catch (error) {
assert.instanceOf(error, Error);
assert.propertyVal(error, 'code', ErrorCode.AppInitializationError);
assert(authorizeCallback.notCalled);
}
});
describe('with a custom receiver', () => {
it('should succeed with no signing secret for the default receiver', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be "should succeed with no signing secret for custom receiver"?

const { App } = await importApp();
const mockReceiver = createMockReceiver();
new App({ receiver: mockReceiver, authorize: sinon.spy() }); // tslint:disable-line:no-unused-expression
});
});
it('should fail when no signing secret for the default receiver is specified', async () => {
const { App } = await importApp();
try {
new App({ authorize: sinon.spy() }); // tslint:disable-line:no-unused-expression
assert.fail();
} catch (error) {
assert.instanceOf(error, Error);
assert.propertyVal(error, 'code', ErrorCode.AppInitializationError);
}
});
it('should initialize MemoryStore conversation store by default', async () => {
const { App, memoryStoreStub, conversationContextStub } = await importApp();
new App({ authorize: sinon.spy(), signingSecret: '' }); // tslint:disable-line:no-unused-expression
assert(memoryStoreStub.calledWithNew);
assert((conversationContextStub as SinonSpy).called);
});
it('should initialize without a conversation store when option is false', async () => {
const { App, conversationContextStub } = await importApp();
// tslint:disable-next-line:no-unused-expression
new App({ convoStore: false, authorize: sinon.spy(), signingSecret: '' });
assert((conversationContextStub as SinonSpy).notCalled);
});
describe('with a custom conversation store', () => {
it('should initialize the conversation store', async () => {
const { App, conversationContextStub } = await importApp();
const mockConvoStore = createMockConvoStore();
// tslint:disable-next-line:no-unused-expression
new App({ convoStore: mockConvoStore, authorize: sinon.spy(), signingSecret: '' });
assert((conversationContextStub as SinonSpy).firstCall.calledWith(mockConvoStore));
});
});
// TODO: tests for ignoreSelf option
// TODO: tests for logger and logLevel option
// TODO: tests for providing botId and botUserId options
// TODO: tests for providing endpoints option
});

describe('#start', () => {
it('should pass calls through to receiver', async () => {
const { App } = await importApp();
const mockReceiver = createMockReceiver();
const mockReturns = Symbol();
const mockParameterList = [Symbol(), Symbol()];
mockReceiver.start = sinon.fake.resolves(mockReturns);
const app = new App({ receiver: mockReceiver, authorize: sinon.spy() });
const actualMockReturns = await app.start(...mockParameterList);
assert.equal(actualMockReturns, mockReturns);
assert.deepEqual(mockParameterList, (mockReceiver.start as SinonSpy).firstCall.args);
});
});

describe('#stop', () => {
it('should pass calls through to receiver', async () => {
const { App } = await importApp();
const mockReceiver = createMockReceiver();
const mockReturns = Symbol();
const mockParameterList = [Symbol(), Symbol()];
mockReceiver.stop = sinon.fake.resolves(mockReturns);
const app = new App({ receiver: mockReceiver, authorize: sinon.spy() });
const actualMockReturns = await app.stop(...mockParameterList);
assert.equal(actualMockReturns, mockReturns);
assert.deepEqual(mockParameterList, (mockReceiver.stop as SinonSpy).firstCall.args);
});
});

describe('event processing', () => {
it('should warn and skip when processing a receiver event with unknown type (never crash)', async () => {
const { App } = await importApp();
const mockReceiver = createMockReceiver();
const invalidReceiverEvents = createInvalidReceiverEvents();
const spyLogger = createSpyLogger();
const spyMiddleware = createSpyMiddleware();
const app = new App({ receiver: mockReceiver, logger: spyLogger, authorize: sinon.spy() });
app.use(spyMiddleware);
for (const event of invalidReceiverEvents) {
(mockReceiver as unknown as EventEmitter).emit('message', event);
}
assert((spyMiddleware as SinonSpy).notCalled);
assert.isAtLeast((spyLogger.warn as SinonSpy).callCount, invalidReceiverEvents.length);
});
it('should warn, send to global error handler, and skip when a receiver event fails authorization', async () => {
const { App } = await importApp();
const mockReceiver = createMockReceiver();
const mockReceiverEvent = createMockReceiverEvent();
const spyLogger = createSpyLogger();
const spyMiddleware = createSpyMiddleware();
const spyErrorHandler = sinon.spy();
const rejection = new Error();
const app = new App({ receiver: mockReceiver, logger: spyLogger, authorize: sinon.fake.rejects(rejection) });
app.use(spyMiddleware);
app.error(spyErrorHandler);
(mockReceiver as unknown as EventEmitter).emit('message', mockReceiverEvent);
await delay();
assert((spyMiddleware as SinonSpy).notCalled);
assert((spyLogger.warn as SinonSpy).called);
assert.instanceOf(spyErrorHandler.firstCall.args[0], Error);
assert.propertyVal(spyErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError);
assert.propertyVal(spyErrorHandler.firstCall.args[0], 'original', rejection);
});
describe('global middleware', () => {
it('should process receiver events in order or #use', async () => {
const { App } = await importApp();
const mockReceiver = createMockReceiver();
const mockReceiverEvent = createMockReceiverEvent();
const spyFirstMiddleware = createSpyMiddleware();
const spySecondMiddleware = createSpyMiddleware();
const app = new App({ receiver: mockReceiver, authorize: sinon.fake.resolves({ botToken: '', botId: '' }) });
app.use(spyFirstMiddleware);
app.use(spySecondMiddleware);
(mockReceiver as unknown as EventEmitter).emit('message', mockReceiverEvent);
await delay();
assert((spyFirstMiddleware as SinonSpy).calledOnce);
assert((spyFirstMiddleware as SinonSpy).calledBefore(spySecondMiddleware as SinonSpy));
assert((spySecondMiddleware as SinonSpy).calledOnce);
});
});
});
});

/* Test Helpers */

async function importAppWhichFetchesOwnBotIds() {
const fakeBotUserId = 'fake_bot_user_id';
const fakeBotId = 'fake_bot_id';
const App = (await rewiremock.module(() => import('./App'), { // tslint:disable-line:variable-name
'@slack/web-api': {
WebClient: class {
public readonly auth = {
test: sinon.fake.resolves({ user_id: fakeBotUserId }),
};
public readonly users = {
info: sinon.fake.resolves({
user: {
profile: {
bot_id: fakeBotId,
},
},
}),
};
public readonly chat = {
postMessage: sinon.fake.resolves({}),
};
},
addAppMetadata: sinon.fake(),
},
})).default;

return {
fakeBotId,
fakeBotUserId,
App,
};
}

async function importApp() {
const memoryStoreStub = sinon.stub();
const conversationContextStub: typeof import('./conversation-store').conversationContext =
sinon.spy(() => createSpyMiddleware());
const App = (await rewiremock.module(() => import('./App'), { // tslint:disable-line:variable-name
'@slack/web-api': {
WebClient: class {
public readonly chat = {
postMessage: sinon.fake.resolves({}),
};
},
addAppMetadata: sinon.fake(),
},
'./conversation-store': {
conversationContext: conversationContextStub,
MemoryStore: memoryStoreStub,
},
})).default;

return {
App,
memoryStoreStub,
conversationContextStub,
};
}

function createSpyMiddleware(): Middleware<AnyMiddlewareArgs> {
return sinon.spy(({ next }) => { next(); });
}

function createMockReceiver(): Receiver {
const mock = new EventEmitter();
(mock as unknown as Receiver).start = sinon.fake.resolves(undefined);
(mock as unknown as Receiver).stop = sinon.fake.resolves(undefined);
return mock as unknown as Receiver;
}

function createMockConvoStore(): ConversationStore {
return {
set: sinon.fake.resolves(undefined),
get: sinon.fake.resolves(undefined),
};
}

function createSpyLogger(): Logger {
return {
setLevel: sinon.fake(),
setName: sinon.fake(),
debug: sinon.fake(),
info: sinon.fake(),
warn: sinon.fake(),
error: sinon.fake(),
};
}

function createInvalidReceiverEvents(): ReceiverEvent[] {
// TODO: create many more invalid receiver events (fuzzing)
return [{
body: {},
respond: sinon.fake(),
ack: sinon.fake(),
}];
}

function createMockReceiverEvent(): ReceiverEvent {
return {
body: {
event: {
},
},
respond: sinon.fake(),
ack: sinon.fake(),
};
}

function delay(ms: number = 0) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
7 changes: 3 additions & 4 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface AppOptions {
ignoreSelf?: boolean;
}

export { LogLevel } from '@slack/logger';
export { LogLevel, Logger } from '@slack/logger';

/** Authorization function - seeds the middleware processing and listeners with an authorization context */
export interface Authorize {
Expand Down Expand Up @@ -171,9 +171,8 @@ export default class App {
}

// Subscribe to messages and errors from the receiver
this.receiver
.on('message', message => this.onIncomingEvent(message))
.on('error', error => this.onGlobalError(error));
this.receiver.on('message', message => this.onIncomingEvent(message));
this.receiver.on('error', error => this.onGlobalError(error));

// Conditionally use a global middleware that ignores events (including messages) that are sent from this app
if (ignoreSelf) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
AuthorizationError,
ActionConstraints,
LogLevel,
Logger,
} from './App';

export { ErrorCode } from './errors';
Expand Down
Loading