-
Notifications
You must be signed in to change notification settings - Fork 429
test: confirm correct app.function handler handling #2139
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
Closed
zimeg
wants to merge
13
commits into
slackapi:pre-stable-fixes
from
zimeg:zimeg/test-custom-functions
Closed
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
3c6492e
Fixes and polish for stable release
misscoded 348fb6f
chore: merge w main
zimeg c1a9127
chore: merge w 'feat-functions'
zimeg 26f1256
test(function): confirm correct handler handling
zimeg ab80d52
chore: merge w 'feat-functions'
zimeg c2d8184
chore: merge w 'pre-stable-fixes'
zimeg 2f5da21
test(fix): instantiate a new fake receiver before each test
zimeg a818873
refactor(test): initialize a constant response for asserts
zimeg d30153f
test(fix): assert failure with builtin asserts for failure cases
zimeg 6eb5fcc
test(fix): quote templated strings with backtick for escape
zimeg 8a85665
test: confirm a fail argument is provided to action handlers
zimeg fa31167
test: confirm false attachments of bot tokens is correct on its own
zimeg 54f3692
refactor: remove repeated logic for selecting a context token
zimeg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,341 @@ | ||
| import 'mocha'; | ||
| import { assert } from 'chai'; | ||
| import rewiremock from 'rewiremock'; | ||
| import sinon, { SinonSpy } from 'sinon'; | ||
| import { WebClientOptions } from '@slack/web-api'; | ||
| import App from './App'; | ||
| import { Override, mergeOverrides } from './test-helpers'; | ||
| import { FunctionInputs, Receiver, ReceiverEvent } from './types'; | ||
|
|
||
| class FakeReceiver implements Receiver { | ||
| private bolt: App | undefined; | ||
|
|
||
| public init = (bolt: App) => { | ||
| this.bolt = bolt; | ||
| }; | ||
|
|
||
| public start = sinon.fake((...params: any[]): Promise<unknown> => Promise.resolve([...params])); | ||
|
|
||
| public stop = sinon.fake((...params: any[]): Promise<unknown> => Promise.resolve([...params])); | ||
|
|
||
| public async sendEvent(event: ReceiverEvent): Promise<void> { | ||
| return this.bolt?.processEvent(event); | ||
| } | ||
| } | ||
|
|
||
| const MOCK_BLOCK_ACTION_ID = 'block_action_id'; | ||
| const MOCK_BOT_TOKEN = 'xoxb-example-001'; | ||
| const MOCK_BOT_ID = 'B0123456789'; | ||
| const MOCK_FUNCTION_BOT_ACCESS_TOKEN = 'xwfp-example-001'; | ||
| const MOCK_FUNCTION_CALLBACK_ID = 'mock_function_callback_id'; | ||
| const MOCK_FUNCTION_EXECUTION_ID = 'Ft0123456789'; | ||
| const MOCK_FUNCTION_INPUT: FunctionInputs = { message: 'hello world' }; | ||
| const MOCK_TEAM_ID = 'T0123456789'; | ||
| const MOCK_USER_ID = 'U0123456789'; | ||
|
|
||
| const MOCK_AUTHORIZATION_RESULT = { | ||
| botToken: MOCK_BOT_TOKEN, | ||
| botId: MOCK_BOT_ID, | ||
| teamId: MOCK_TEAM_ID, | ||
| }; | ||
|
|
||
| describe('App CustomFunction middleware', () => { | ||
| let fakeReceiver: FakeReceiver; | ||
| let MockApp: typeof App; | ||
| let fakeFunctionsSuccess: SinonSpy; | ||
| let fakeFunctionsError: SinonSpy; | ||
|
|
||
| beforeEach(async () => { | ||
| fakeReceiver = new FakeReceiver(); | ||
| fakeFunctionsSuccess = sinon.fake.resolves({ ok: true }); | ||
| fakeFunctionsError = sinon.fake.resolves({ ok: true }); | ||
|
|
||
| const overrides = mergeOverrides(...[ | ||
| withFunctionsComplete(fakeFunctionsSuccess, fakeFunctionsError), | ||
| withNoopAppMetadata(), | ||
| ]); | ||
| MockApp = await importApp(overrides); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| sinon.restore(); | ||
| }); | ||
|
|
||
| it('completes a function with success using values from the execution event', async () => { | ||
| const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, inputs }) => { | ||
| const response = await complete({ outputs: inputs }); | ||
| assert(response.ok); | ||
| assert.equal(client.token, MOCK_FUNCTION_BOT_ACCESS_TOKEN); | ||
| }); | ||
|
|
||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); | ||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.calledOnce); | ||
| assert(fakeFunctionsSuccess.calledWith({ | ||
| token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| outputs: MOCK_FUNCTION_INPUT, | ||
| })); | ||
| }); | ||
|
|
||
| it('completes a function with error after running listener middleware', async () => { | ||
| const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, | ||
| async ({ context, next }) => { | ||
| context.example = '12'; | ||
| await next(); | ||
| }, | ||
| async ({ context, fail }) => { | ||
| const response = await fail({ error: context.example }); | ||
| assert(response.ok); | ||
| }); | ||
|
|
||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| assert(fakeFunctionsError.calledOnce); | ||
| assert(fakeFunctionsError.calledWith({ | ||
| token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| error: '12', | ||
| })); | ||
| }); | ||
|
|
||
| it('uses the application bot token when not attaching the function token', async () => { | ||
| const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| attachFunctionToken: false, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, fail }) => { | ||
| const completed = await complete(); | ||
| assert(completed.ok); | ||
| const failed = await fail({ error: 'ohno' }); | ||
| assert(failed.ok); | ||
| assert.equal(client.token, MOCK_BOT_TOKEN); | ||
| }); | ||
|
|
||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); | ||
| assert(fakeFunctionsSuccess.calledOnce); | ||
| assert(fakeFunctionsSuccess.calledBefore(fakeFunctionsError)); | ||
| assert(fakeFunctionsSuccess.calledWith({ | ||
| token: MOCK_BOT_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| outputs: {}, | ||
| })); | ||
| assert(fakeFunctionsError.calledOnce); | ||
| assert(fakeFunctionsError.calledWith({ | ||
| token: MOCK_BOT_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| error: 'ohno', | ||
| })); | ||
| }); | ||
|
|
||
| it('skips function handlers without a matching function callback id', async () => { | ||
| const dummyFunctionExecutionEvent = createFunctionExecutionEvent({ | ||
| mockFunctionCallbackId: 'unexpected_callback_id', | ||
| }); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { | ||
| assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); | ||
| }); | ||
|
|
||
| await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); | ||
| }); | ||
|
|
||
| it('extracts function execution context for success in block actions', async () => { | ||
| const dummyBlockActionEvent = createBlockActionEvent(); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { | ||
| assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); | ||
| }); | ||
|
|
||
| app.action(MOCK_BLOCK_ACTION_ID, async (args) => { | ||
| const { context, complete } = args as any; | ||
| const response = await complete(); | ||
| assert(response.ok); | ||
| assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); | ||
| assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); | ||
| assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); | ||
| }); | ||
|
|
||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| await fakeReceiver.sendEvent(dummyBlockActionEvent); | ||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.called); | ||
| assert(fakeFunctionsSuccess.calledWith({ | ||
| token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| outputs: {}, | ||
| })); | ||
| }); | ||
|
zimeg marked this conversation as resolved.
|
||
|
|
||
| it('extracts function execution context for failure in block actions', async () => { | ||
| const dummyBlockActionEvent = createBlockActionEvent(); | ||
|
|
||
| const app = new MockApp({ | ||
| authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), | ||
| receiver: fakeReceiver, | ||
| }); | ||
|
|
||
| app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { | ||
| assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); | ||
| }); | ||
|
|
||
| app.action(MOCK_BLOCK_ACTION_ID, async (args) => { | ||
| const { context, fail, inputs } = args as any; | ||
| const response = await fail({ error: inputs.message }); | ||
| assert(response.ok); | ||
| assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); | ||
| assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); | ||
| assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); | ||
| }); | ||
|
|
||
| assert(fakeFunctionsError.notCalled); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| await fakeReceiver.sendEvent(dummyBlockActionEvent); | ||
| assert(fakeFunctionsSuccess.notCalled); | ||
| assert(fakeFunctionsError.called); | ||
| assert(fakeFunctionsError.calledWith({ | ||
| token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| function_execution_id: MOCK_FUNCTION_EXECUTION_ID, | ||
| error: MOCK_FUNCTION_INPUT.message, | ||
| })); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * Generators for mock function events are found below | ||
| */ | ||
|
|
||
| interface MockFunctionContextOverrides { | ||
| mockFunctionBotAccessToken?: string, | ||
| mockFunctionCallbackId?: string, | ||
| mockFunctionExecutionId?: string, | ||
| mockFunctionInput?: FunctionInputs, | ||
| } | ||
|
|
||
| function createFunctionExecutionEvent(overrides?: MockFunctionContextOverrides): ReceiverEvent { | ||
| const defaults = { | ||
| mockFunctionBotAccessToken: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| mockFunctionCallbackId: MOCK_FUNCTION_CALLBACK_ID, | ||
| mockFunctionExecutionId: MOCK_FUNCTION_EXECUTION_ID, | ||
| mockFunctionInput: MOCK_FUNCTION_INPUT, | ||
| }; | ||
| const values = Object.assign(defaults, overrides); | ||
| return { | ||
| ack: () => Promise.resolve(undefined), | ||
| body: { | ||
| event: { | ||
| type: 'function_executed', | ||
| bot_access_token: values.mockFunctionBotAccessToken, | ||
| function: { | ||
| callback_id: values.mockFunctionCallbackId, | ||
| }, | ||
| function_execution_id: values.mockFunctionExecutionId, | ||
| inputs: { | ||
| message: values.mockFunctionInput.message, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function createBlockActionEvent(overrides?: MockFunctionContextOverrides): ReceiverEvent { | ||
| const defaults = { | ||
| mockFunctionBotAccessToken: MOCK_FUNCTION_BOT_ACCESS_TOKEN, | ||
| mockFunctionCallbackId: MOCK_FUNCTION_CALLBACK_ID, | ||
| mockFunctionExecutionId: MOCK_FUNCTION_EXECUTION_ID, | ||
| mockFunctionInput: MOCK_FUNCTION_INPUT, | ||
| }; | ||
| const values = Object.assign(defaults, overrides); | ||
| return { | ||
| ack: () => Promise.resolve(undefined), | ||
| body: { | ||
| type: 'block_actions', | ||
| team: { id: MOCK_TEAM_ID }, | ||
| user: { id: MOCK_USER_ID }, | ||
| actions: [ | ||
| { | ||
| action_id: MOCK_BLOCK_ACTION_ID, | ||
| }, | ||
| ], | ||
| bot_access_token: values.mockFunctionBotAccessToken, | ||
| function_data: { | ||
| execution_id: values.mockFunctionExecutionId, | ||
| inputs: { | ||
| message: values.mockFunctionInput.message, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Overrides for spying on mocks are below this comment | ||
| */ | ||
|
|
||
| async function importApp( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is nice 💯! |
||
| overrides: Override = mergeOverrides(withNoopAppMetadata()), | ||
| ): Promise<typeof import('./App').default> { | ||
| return (await rewiremock.module(() => import('./App'), overrides)).default; | ||
| } | ||
|
|
||
| function withFunctionsComplete(spySuccess: SinonSpy, spyError: SinonSpy): Override { | ||
| return { | ||
| '@slack/web-api': { | ||
| WebClient: class { | ||
| public readonly token?: string; | ||
|
|
||
| public constructor(token: string, _options?: WebClientOptions) { | ||
| this.token = token; | ||
| } | ||
|
|
||
| public functions = { | ||
| completeSuccess: spySuccess, | ||
| completeError: spyError, | ||
| }; | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function withNoopAppMetadata(): Override { | ||
| return { | ||
| '@slack/web-api': { | ||
| addAppMetadata: sinon.fake(), | ||
| }, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.