0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Use a separate Stripe Connect account for each worker

refs: https://github.com/TryGhost/DevOps/issues/78

This still has problems with parallel tests causing issues for each other, but is so close to a full pass test run
This commit is contained in:
Sam Lord 2023-10-11 15:27:58 +01:00 committed by Sam Lord
parent bf815adc5a
commit 27b69f083c
4 changed files with 222 additions and 177 deletions

View file

@ -8,9 +8,11 @@ const {allowStripe, mockMail} = require('../../utils/e2e-framework-mock-manager'
const MailgunClient = require('@tryghost/mailgun-client');
const sinon = require('sinon');
const ObjectID = require('bson-objectid').default;
const Stripe = require('stripe').Stripe;
const configUtils = require('../../utils/configUtils');
const startWebhookServer = (port) => {
const command = `stripe listen --forward-to http://127.0.0.1:${port}/members/webhooks/stripe/`;
const command = `stripe listen --forward-connect-to http://127.0.0.1:${port}/members/webhooks/stripe/`;
return spawn(command.split(' ')[0], command.split(' ').slice(1));
};
@ -20,14 +22,43 @@ const getWebhookSecret = async () => {
return webhookSecret.toString().trim();
};
const generateStripeIntegrationToken = async () => {
const getStripeAccountId = async (workerIndex) => {
let accountId;
const accountEmail = `test${workerIndex}@example.com`;
const secretKey = process.env.STRIPE_SECRET_KEY;
const stripe = new Stripe(secretKey, {
apiVersion: '2020-08-27'
});
const accounts = await stripe.accounts.list();
if (accounts.data.length > 0) {
const account = accounts.data.find(acc => acc.email === accountEmail);
if (account) {
await stripe.accounts.del(account.id);
}
}
if (!accountId) {
const account = await stripe.accounts.create({
type: 'standard',
email: accountEmail,
business_type: 'company',
company: {
name: `Test Company ${workerIndex}`
}
});
accountId = account.id;
}
return accountId;
};
const generateStripeIntegrationToken = async (accountId) => {
if (!('STRIPE_PUBLISHABLE_KEY' in process.env) || !('STRIPE_SECRET_KEY' in process.env)) {
throw new Error('Missing STRIPE_PUBLISHABLE_KEY or STRIPE_SECRET_KEY environment variables');
}
const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY;
const secretKey = process.env.STRIPE_SECRET_KEY;
const accountId = process.env.STRIPE_ACCOUNT_ID ?? JSON.parse((await promisify(exec)('stripe get account')).stdout).id;
return Buffer.from(JSON.stringify({
a: secretKey,
@ -39,7 +70,6 @@ const generateStripeIntegrationToken = async () => {
// Global promises for webhook secret / Stripe integration token
const webhookSecretPromise = getWebhookSecret();
const stripeIntegrationTokenPromise = generateStripeIntegrationToken();
module.exports = base.test.extend({
baseURL: async ({port, baseURL}, use) => {
@ -64,31 +94,35 @@ module.exports = base.test.extend({
const currentDate = new Date();
const formattedDate = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${String(currentDate.getHours()).padStart(2, '0')}-${String(currentDate.getMinutes()).padStart(2, '0')}-${String(currentDate.getSeconds()).padStart(2, '0')}`;
process.env.database__connection__filename = `/tmp/ghost-playwright.${workerInfo.workerIndex}.${formattedDate}.db`;
configUtils.set('database:connection:filename', process.env.database__connection__filename);
configUtils.set('server:port', port);
configUtils.set('url', `http://127.0.0.1:${port}`);
const stripeAccountId = await getStripeAccountId(workerInfo.workerIndex);
const stripeIntegrationToken = await generateStripeIntegrationToken(stripeAccountId);
const WebhookManager = require('../../../../stripe/lib/WebhookManager');
const originalParseWebhook = WebhookManager.prototype.parseWebhook;
const sandbox = sinon.createSandbox();
const originalConfigGet = config.get.bind(config);
sandbox.stub(config, 'get').callsFake((key) => {
if (key === 'database:connection:filename') {
return process.env.database__connection__filename;
sandbox.stub(WebhookManager.prototype, 'parseWebhook').callsFake(function (body, signature) {
const parsedBody = JSON.parse(body);
if (!('account' in parsedBody)) {
throw new Error('Webhook without account');
} else if (parsedBody.account !== stripeAccountId) {
throw new Error('Webhook for wrong account');
} else {
return originalParseWebhook.call(this, body, signature);
}
if (key === 'database') {
return {
client: 'sqlite3',
connection: {
filename: process.env.database__connection__filename
},
useNullAsDefault: true,
debug: false
};
}
if (key === 'server') {
return {
port
};
}
if (key === 'url') {
return `http://127.0.0.1:${port}`;
}
return originalConfigGet(key);
});
const StripeAPI = require('../../../../stripe/lib/StripeAPI');
const originalStripeConfigure = StripeAPI.prototype.configure;
sandbox.stub(StripeAPI.prototype, 'configure').callsFake(function (stripeConfig) {
originalStripeConfigure.call(this, stripeConfig);
this._stripe = new Stripe(stripeConfig.secretKey, {
apiVersion: '2020-08-27',
stripeAccount: stripeAccountId
});
});
const stripeServer = startWebhookServer(port);
@ -124,7 +158,7 @@ module.exports = base.test.extend({
});
await setupGhost(page);
await setupStripe(page, await stripeIntegrationTokenPromise);
await setupStripe(page, stripeIntegrationToken);
await setupMailgun(page);
await enableLabs(page);
const state = await page.context().storageState();
@ -132,15 +166,16 @@ module.exports = base.test.extend({
await page.close();
// Use the server in the tests.
await use({
server,
state
});
// Cleanup.
const {stopGhost} = require('../../utils/e2e-utils');
await stopGhost();
stripeServer.kill();
sandbox.restore();
try {
await use({
server,
state
});
} finally {
const {stopGhost} = require('../../utils/e2e-utils');
await stopGhost();
stripeServer.kill();
sandbox.restore();
}
}, {scope: 'worker', auto: true}]
});

View file

@ -23,7 +23,9 @@ const addNewsletter = async (page) => {
test.describe('Portal', () => {
test.describe('Member actions', () => {
test.describe.configure({retries: 1});
// Use serial mode as the order of tests matters, we create newsletters during the tests
// TODO: Use a `before` block to create all the requisite newsletters before the tests run
test.describe.configure({retries: 1, mode: 'serial'});
test('can log out', async ({page}) => {
// create a new free member

View file

@ -10,7 +10,7 @@ test.describe('Portal', () => {
await deleteAllMembers(page);
// add a new tier for offers
const tierName = 'Portal Tier';
const tierName = 'Trial Tier';
await createTier(page, {
name: tierName,
monthlyPrice: 6,
@ -44,7 +44,7 @@ test.describe('Portal', () => {
// fill member details and click start trial
await portalFrame.locator('[data-test-input="input-name"]').fill('Testy McTesterson');
await portalFrame.locator('[data-test-input="input-email"]').fill('testy@example.com');
await portalFrame.locator('[data-test-input="input-email"]').fill('testy+trial@example.com');
await portalFrame.getByRole('button', {name: 'Start 14-day free trial'}).click();
// handle newsletter selection page if it opens and click continue
@ -67,7 +67,7 @@ test.describe('Portal', () => {
await page.locator('.gh-nav a[href="#/members/"]').click();
// 1 member, should be Testy, on Portal Tier
await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: 'Testy McTesterson testy+trial@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible();
// Ensure the offer redemption count was bumped
@ -82,7 +82,7 @@ test.describe('Portal', () => {
await deleteAllMembers(page);
// add new tier
const tierName = 'Portal Tier';
const tierName = 'One-off Tier';
await createTier(page, {
name: tierName,
monthlyPrice: 6,
@ -115,7 +115,7 @@ test.describe('Portal', () => {
// fill member details and continue
await portalFrame.locator('#input-name').fill('Testy McTesterson');
await portalFrame.locator('#input-email').fill('testy@example.com');
await portalFrame.locator('#input-email').fill('testy+oneoff@example.com');
await portalFrame.getByRole('button', {name: 'Continue'}).click();
// check if newsletter selection screen is shown and continue
@ -137,7 +137,7 @@ test.describe('Portal', () => {
await page.locator('.gh-nav a[href="#/members/"]').click();
// 1 member, should be Testy, on Portal Tier
await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: 'Testy McTesterson testy+oneoff@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible();
});
@ -147,7 +147,7 @@ test.describe('Portal', () => {
await deleteAllMembers(page);
// add new tier
const tierName = 'Portal Tier';
const tierName = 'Multiple-month Tier';
await createTier(page, {
name: tierName,
monthlyPrice: 6,
@ -184,7 +184,7 @@ test.describe('Portal', () => {
// fill member details and continue
await portalFrame.locator('#input-name').fill('Testy McTesterson');
await portalFrame.locator('#input-email').fill('testy@example.com');
await portalFrame.locator('#input-email').fill('testy+multi@example.com');
await portalFrame.getByRole('button', {name: 'Continue'}).click();
// check newsletter selection if shown and continue
@ -205,7 +205,7 @@ test.describe('Portal', () => {
await page.locator('.gh-nav a[href="#/members/"]').click();
// 1 member, should be Testy, on Portal Tier
await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: 'Testy McTesterson testy+multi@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible();
});
@ -215,7 +215,7 @@ test.describe('Portal', () => {
await deleteAllMembers(page);
// add tier
const tierName = 'Portal Tier';
const tierName = 'Forever Tier';
await createTier(page, {
name: tierName,
monthlyPrice: 6,
@ -251,7 +251,7 @@ test.describe('Portal', () => {
// fill member details and continue
await portalFrame.locator('#input-name').fill('Testy McTesterson');
await portalFrame.locator('#input-email').fill('testy@example.com');
await portalFrame.locator('#input-email').fill('testy+forever@example.com');
await portalFrame.getByRole('button', {name: 'Continue'}).click();
// check if newsletter selection page is shown and continue
@ -270,7 +270,7 @@ test.describe('Portal', () => {
await page.locator('.gh-nav a[href="#/members/"]').click();
// 1 member, should be Testy, on Portal Tier
await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: 'Testy McTesterson testy+forever@example.com'}), 'Should have 1 paid member').toBeVisible();
await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible();
});
@ -278,7 +278,7 @@ test.describe('Portal', () => {
await page.goto('/ghost');
// Create a new tier to attach offer to
const tierName = 'Portal Tier';
const tierName = 'Archive Test Tier';
await createTier(page, {
name: tierName,
monthlyPrice: 6,

View file

@ -1,156 +1,164 @@
const {expect} = require('@playwright/test');
const test = require('../fixtures/ghost-test');
const {completeStripeSubscription, createMember, impersonateMember} = require('../utils');
const {completeStripeSubscription, createMember, createTier, impersonateMember} = require('../utils');
const tierName = 'Upgrade Tests';
test.describe('Portal', () => {
test.describe('Upgrade: Comped Member', () => {
test('allows comped member to upgrade to paid tier', async ({page}) => {
const tierName = 'The Local Test';
// create a new member
await page.goto('/ghost');
await createMember(page, {
name: 'Testy McTest',
email: 'testy+upgradecompedportal@example.com',
note: 'Testy McTest is a test member'
});
//get the url of the current member on admin
const memberUrl = page.url();
// Give member comped subscription
await page.locator('[data-test-button="add-complimentary"]').click();
await page.locator('[data-test-button="save-comp-tier"]').first().click({
delay: 500
});
await page.waitForLoadState('networkidle');
await impersonateMember(page);
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
//await page.pause();
// open portal, go to plans and click continue to select the first plan(yearly)
await portalTriggerButton.click();
await portalFrame.getByRole('button', {name: 'Change'}).click();
// select the tier for checkout (yearly)
await choseTierByName(portalFrame, tierName);
// complete stripe checkout
await completeStripeSubscription(page);
// open portal and check that member has been upgraded to paid tier
await portalTriggerButton.click();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible();
await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible();
// check that member has been upgraded in admin and a tier exists for them
await page.goto(memberUrl);
await expect(page.locator('[data-test-tier]').first()).toBeVisible();
});
});
test.describe('Upgrade: Single Tier', () => {
// Because memberUrl is set during first test, we need to run these tests in series
test.describe('Upgrades', () => {
// Tier created in first test used in subsequent tests
test.describe.configure({mode: 'serial'});
let memberUrl;
const tierName = 'The Local Test';
const member = {
name: 'Testy McTest',
email: 'testy+upgradeportal@example.com',
note: 'Testy McTest is a test member'
};
test.describe('Upgrade: Comped Member', () => {
test('allows comped member to upgrade to paid tier', async ({page}) => {
// create a new member
await page.goto('/ghost');
await createTier(page, {
name: tierName,
monthlyPrice: 5,
yearlyPrice: 50
});
await createMember(page, {
name: 'Testy McTest',
email: 'testy+upgradecompedportal@example.com',
note: 'Testy McTest is a test member'
});
test('allows free member upgrade to paid tier', async ({page}) => {
await page.goto('/ghost');
//get the url of the current member on admin
const memberUrl = page.url();
// create a new free member
await page.goto('/ghost');
await createMember(page, member);
// Give member comped subscription
await page.locator('[data-test-button="add-complimentary"]').click();
await page.locator('[data-test-button="save-comp-tier"]').first().click({
delay: 500
});
//store the url of the member detail page
memberUrl = page.url();
await page.waitForLoadState('networkidle');
await impersonateMember(page);
// impersonate the member on frontend
await impersonateMember(page);
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
// open portal, go to plans and click continue to select the first plan(yearly)
await portalTriggerButton.click();
await portalFrame.getByRole('button', {name: 'Change'}).click();
// open portal, go to plans and click continue to select the first plan(yearly)
await portalTriggerButton.click();
// verify the member we created is logged in
await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible();
// view plans button only shows for free member
await portalFrame.getByRole('button', {name: 'View plans'}).click();
// select the tier for checkout (yearly)
await choseTierByName(portalFrame, tierName);
// select the tier for checkout (yearly)
await choseTierByName(portalFrame, tierName);
// complete stripe checkout
await completeStripeSubscription(page);
// complete stripe checkout
await completeStripeSubscription(page);
// open portal and check that member has been upgraded to paid tier
await portalTriggerButton.click();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible();
await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible();
// open portal and check that member has been upgraded to paid tier
await portalTriggerButton.click();
// verify member's tier, price and card details
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible();
// verify member's tier on member detail page in admin
await page.goto(memberUrl);
const tierCard = await page.locator('[data-test-tier]').first();
const tierText = await tierCard.locator('[data-test-text="tier-name"]');
await expect(tierCard).toBeVisible();
await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName));
// check that member has been upgraded in admin and a tier exists for them
await page.goto(memberUrl);
await expect(page.locator('[data-test-tier]').first()).toBeVisible();
});
});
test('allows member to switch plans', async ({page}) => {
// go to member detail page in admin
await page.goto(memberUrl);
test.describe('Upgrade: Single Tier', () => {
// Because memberUrl is set during first test, we need to run these tests in series
test.describe.configure({mode: 'serial'});
// impersonate the member on frontend
await impersonateMember(page);
let memberUrl;
const member = {
name: 'Testy McTest',
email: 'testy+upgradeportal@example.com',
note: 'Testy McTest is a test member'
};
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
test('allows free member upgrade to paid tier', async ({page}) => {
await page.goto('/ghost');
// open portal
await portalTriggerButton.click();
// create a new free member
await page.goto('/ghost');
await createMember(page, member);
// test member can switch to monthly plan from yearly
await portalFrame.locator('[data-test-button="change-plan"]').click();
//store the url of the member detail page
memberUrl = page.url();
await portalFrame.locator('[data-test-button="switch-monthly"]').click();
// impersonate the member on frontend
await impersonateMember(page);
// select the monthly plan
await choseTierByName(portalFrame, tierName);
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
// confirm the switch
await portalFrame.locator('[data-test-button="confirm-action"]').first().click();
// verify member has switched to monthly plan
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$5.00/month')).toBeVisible();
// open portal, go to plans and click continue to select the first plan(yearly)
await portalTriggerButton.click();
// verify the member we created is logged in
await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible();
// view plans button only shows for free member
await portalFrame.getByRole('button', {name: 'View plans'}).click();
// test member can switch back to yearly
await portalFrame.locator('[data-test-button="change-plan"]').click();
await portalFrame.locator('[data-test-button="switch-yearly"]').click();
// select the monthly plan
await choseTierByName(portalFrame, tierName);
// confirm the switch
await portalFrame.locator('[data-test-button="confirm-action"]').first().click();
// verify member has switched to yearly plan, timeout added to allow for delays
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
// select the tier for checkout (yearly)
await choseTierByName(portalFrame, tierName);
// complete stripe checkout
await completeStripeSubscription(page);
// open portal and check that member has been upgraded to paid tier
await portalTriggerButton.click();
// verify member's tier, price and card details
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible();
// verify member's tier on member detail page in admin
await page.goto(memberUrl);
const tierCard = await page.locator('[data-test-tier]').first();
const tierText = await tierCard.locator('[data-test-text="tier-name"]');
await expect(tierCard).toBeVisible();
await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName));
});
test('allows member to switch plans', async ({page}) => {
// go to member detail page in admin
await page.goto(memberUrl);
// impersonate the member on frontend
await impersonateMember(page);
const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
// open portal
await portalTriggerButton.click();
// test member can switch to monthly plan from yearly
await portalFrame.locator('[data-test-button="change-plan"]').click();
await portalFrame.locator('[data-test-button="switch-monthly"]').click();
// select the monthly plan
await choseTierByName(portalFrame, tierName);
// confirm the switch
await portalFrame.locator('[data-test-button="confirm-action"]').first().click();
// verify member has switched to monthly plan
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$5.00/month')).toBeVisible();
// test member can switch back to yearly
await portalFrame.locator('[data-test-button="change-plan"]').click();
await portalFrame.locator('[data-test-button="switch-yearly"]').click();
// select the monthly plan
await choseTierByName(portalFrame, tierName);
// confirm the switch
await portalFrame.locator('[data-test-button="confirm-action"]').first().click();
// verify member has switched to yearly plan, timeout added to allow for delays
await expect(portalFrame.getByText(tierName)).toBeVisible();
await expect(portalFrame.getByText('$50.00/year')).toBeVisible();
});
});
});
});
async function choseTierByName(portalFrame, tierName) {
const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tierName}).first();
async function choseTierByName(portalFrame, tier) {
const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tier}).first();
await portalTierCard.locator('[data-test-button="select-tier"]').click();
}