From 0f4aeaaa802738f06aed066ece39f9666f2ec1ce Mon Sep 17 00:00:00 2001 From: Naz Date: Thu, 26 May 2022 11:11:41 +0800 Subject: [PATCH] Added basic framework for webhook e2e tests refs https://github.com/TryGhost/Toolbox/issues/320 - This is an **MVP** to be able to intercept and match webhook request snapshots. The concept is similar to the one used in API E2E tests using same "matchBodySnapshot" and other "match*" methods to test the webhook **request** data - Next up here would be: 1. Header matcher 2. Mocking more than one webhook (and doing something nicer with the way the fixture data is inserted, does this logic belong to the mock-receiver? --- .../__snapshots__/posts.test.js.snap | 113 ++++++++++++++++++ test/e2e-webhooks/posts.test.js | 52 ++++++++ test/utils/e2e-framework-mock-manager.js | 12 ++ test/utils/webhook-mock-receiver.js | 91 ++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 test/e2e-webhooks/__snapshots__/posts.test.js.snap create mode 100644 test/e2e-webhooks/posts.test.js create mode 100644 test/utils/webhook-mock-receiver.js diff --git a/test/e2e-webhooks/__snapshots__/posts.test.js.snap b/test/e2e-webhooks/__snapshots__/posts.test.js.snap new file mode 100644 index 00000000000..856461fcb78 --- /dev/null +++ b/test/e2e-webhooks/__snapshots__/posts.test.js.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`post.* events post.published even is triggered 1: [body] 1`] = ` +Object { + "post": Object { + "current": Object { + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": "62905373e751ff5d4a98db0f", + "created_at": "2022-05-27T04:28:35.000Z", + "custom_excerpt": null, + "custom_template": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": null, + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": null, + "id": "62905373e751ff5d4a98db0f", + "meta_description": null, + "meta_title": null, + "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"ghostVersion\\":\\"4.0\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"\\"]]]]}", + "og_description": null, + "og_image": null, + "og_title": null, + "plaintext": null, + "primary_tag": null, + "published_at": "2022-05-27T04:28:40.000Z", + "slug": "webhookz", + "status": "published", + "tags": Array [], + "tiers": Array [ + Object { + "active": true, + "created_at": "2022-05-27T04:28:30.000Z", + "description": null, + "id": "6290536ee751ff5d4a98d92a", + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "type": "paid", + "updated_at": "2022-05-27T04:28:30.000Z", + "visibility": "public", + "welcome_page_url": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": "2022-05-27T04:28:30.000Z", + "description": null, + "id": "6290536ee751ff5d4a98d92b", + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "type": "free", + "updated_at": "2022-05-27T04:28:30.000Z", + "visibility": "public", + "welcome_page_url": null, + "yearly_price_id": null, + }, + ], + "title": "webhookz", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": "2022-05-27T04:28:40.000Z", + "url": "http://127.0.0.1:2369/404/", + "uuid": "e3e0900e-2d00-463f-b83f-c8fa44bea3ae", + "visibility": "public", + }, + "previous": Object { + "published_at": null, + "status": "draft", + "tiers": Array [ + Object { + "active": true, + "created_at": "2022-05-27T04:28:30.000Z", + "description": null, + "id": "6290536ee751ff5d4a98d92a", + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "type": "paid", + "updated_at": "2022-05-27T04:28:30.000Z", + "visibility": "public", + "welcome_page_url": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": "2022-05-27T04:28:30.000Z", + "description": null, + "id": "6290536ee751ff5d4a98d92b", + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "type": "free", + "updated_at": "2022-05-27T04:28:30.000Z", + "visibility": "public", + "welcome_page_url": null, + "yearly_price_id": null, + }, + ], + "updated_at": "2022-05-27T04:28:35.000Z", + }, + }, +} +`; diff --git a/test/e2e-webhooks/posts.test.js b/test/e2e-webhooks/posts.test.js new file mode 100644 index 00000000000..6cd0a6aa67d --- /dev/null +++ b/test/e2e-webhooks/posts.test.js @@ -0,0 +1,52 @@ +const {agentProvider, mockManager, fixtureManager} = require('../utils/e2e-framework'); + +describe('post.* events', function () { + let adminAPIAgent; + let webhookMockReceiver; + + before(async function () { + adminAPIAgent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('integrations'); + await adminAPIAgent.loginAsOwner(); + }); + + beforeEach(function () { + webhookMockReceiver = mockManager.mockWebhookRequests(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('post.published even is triggered', async function () { + await webhookMockReceiver.mock('post.published'); + await fixtureManager.insertWebhook({ + event: 'post.published', + url: 'https://test-webhook-receiver.com/webhook' + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'webhookz', + status: 'draft' + }] + }) + .expectStatus(201); + + const id = res.body.posts[0].id; + const updatedPost = res.body.posts[0]; + updatedPost.status = 'published'; + + await adminAPIAgent + .put('posts/' + id) + .body({ + posts: [updatedPost] + }) + .expectStatus(200); + + await webhookMockReceiver + .matchBodySnapshot(); + }); +}); diff --git a/test/utils/e2e-framework-mock-manager.js b/test/utils/e2e-framework-mock-manager.js index b6cd0a9af67..780319470cc 100644 --- a/test/utils/e2e-framework-mock-manager.js +++ b/test/utils/e2e-framework-mock-manager.js @@ -5,6 +5,7 @@ const nock = require('nock'); // Helper services const configUtils = require('./configUtils'); +const WebhookMockReceiver = require('./webhook-mock-receiver'); let mocks = {}; let emailCount = 0; @@ -48,6 +49,12 @@ const mockMail = (response = 'Mail is disabled') => { return mocks.mail; }; +const mockWebhookRequests = () => { + mocks.webhookMockReceiver = new WebhookMockReceiver(); + + return mocks.webhookMockReceiver; +}; + const sentEmailCount = (count) => { if (!mocks.mail) { throw new errors.IncorrectUsageError({ @@ -139,6 +146,10 @@ const restore = () => { emailCount = 0; nock.cleanAll(); nock.enableNetConnect(); + + if (mocks.webhookMockReceiver) { + mocks.webhookMockReceiver.reset(); + } }; module.exports = { @@ -148,6 +159,7 @@ module.exports = { mockStripe, mockLabsEnabled, mockLabsDisabled, + mockWebhookRequests, restore, assert: { sentEmailCount, diff --git a/test/utils/webhook-mock-receiver.js b/test/utils/webhook-mock-receiver.js new file mode 100644 index 00000000000..f6d295bd642 --- /dev/null +++ b/test/utils/webhook-mock-receiver.js @@ -0,0 +1,91 @@ +const nock = require('nock'); +const assert = require('assert'); +const {snapshotManager} = require('@tryghost/jest-snapshot'); + +// NOTE: this is a shameless copy-pasta from express-test utils. +// needs to get refactored into reusable package, just like this whole module +const makeMessageFromMatchMessage = (message, errorMessage) => { + const messageLines = message.split('\n'); + messageLines.splice(0, 1, errorMessage); + return messageLines.join('\n'); +}; + +class WebhookMockReceiver { + constructor() { + this.bodyResponse; + this.receiver; + this.recordBodyResponse = this.recordBodyResponse.bind(this); + } + + recordBodyResponse(body) { + this.bodyResponse = {body}; + + // let the nock continue with the response + return true; + } + + mock() { + this.receiver = nock('https://test-webhook-receiver.com') + .post('/webhook', this.recordBodyResponse) + .reply(200, {status: 'OK'}); + + return this; + } + + reset() { + nock.restore(); + this.bodyResponse = undefined; + } + + + _assertSnapshot(response, assertion) { + const {properties, field, error} = assertion; + + if (!response[field]) { + error.message = `Unable to match snapshot on undefined field ${field} ${error.contextString}`; + error.expected = field; + error.actual = 'undefined'; + assert.notEqual(response[field], undefined, error); + } + + const hint = `[${field}]`; + const match = snapshotManager.match(response[field], properties, hint); + + Object.keys(properties).forEach((prop) => { + const errorMessage = `"response.${field}" is missing the expected property "${prop}"`; + error.message = makeMessageFromMatchMessage(match.message(), errorMessage); + error.expected = prop; + error.actual = 'undefined'; + error.showDiff = false; // Disable mocha's diff output as it's already present in match.message() + + assert.notEqual(response[field][prop], undefined, error); + }); + + if (match.pass !== true) { + const errorMessage = `"response.${field}" does not match snapshot.`; + error.message = makeMessageFromMatchMessage(match.message(), errorMessage); + error.expected = match.expected; + error.actual = match.actual; + error.showDiff = false; // Disable mocha's diff output as it's already present in match.message() + } + + assert.equal(match.pass, true, error); + } + + async matchBodySnapshot(properties = {}) { + while (!this.receiver.isDone()) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + let assertion = { + fn: this._assertSnapshot, + properties: properties, + field: 'body', + type: 'body' + }; + + this._assertSnapshot(this.bodyResponse, assertion); + } +} + +module.exports = WebhookMockReceiver;