diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index ed5ff6f..9bed47c 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -77,7 +77,7 @@ The join form specs (the table below) are listed with their test counts. The set | Tab | Spec | Covered | |---|---|---| | Features | `features.spec.ts`, `features-integrations.spec.ts` | Every setting. Integration switches: refused without credentials, saved with them, payment-step and form effects. Use Stripe sets global plans up in Stripe on save (fake API). Known problem: JOIN-161 (a rejected Stripe key crashes the save). | -| Membership Plans | | Not yet | +| Membership Plans | `membership-plans.spec.ts` | Every setting. Plans: added, edited and removed, stored under their ID, offered on join forms without their own plans; each plan field's effect on the form and on joins (`/join`, including the custom-amount minimum); at least one plan required, and Name and Price required by the page (not by the plugin: JOIN-169); each frequency and currency set up in Stripe (fake API). Tags: defaults; on a join, the plan's Add Tags added and the lapsed, cancelled, lapsing and other plans' tags removed; lapsed, cancelled and lapsing tags applied by Stripe webhook events; tier changes swap plan tags; no tags while another subscription is active (fake Mailchimp). Known problems: JOIN-166 (duplicate Stripe prices for prices with pence), JOIN-169 (Name and Price not checked by the plugin). Open question: JOIN-170 (removed plans stay joinable). | | Theme | `theme.spec.ts` | Every setting: colour defaults, each colour reaching the form's styles (the primary colour on its buttons), custom CSS applied. | | Copy | `copy.spec.ts` | Every setting: each one's default (or none), the fallbacks for blank sidebar, stage label and joining verb, and where each shows (details, plan and bank-details steps, confirmation step, sidebar). Known problems: JOIN-162 (Home Address Copy never shows), JOIN-163 (day-of-month copy unused), JOIN-164 (organisation details marked required but not enforced). | | Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | diff --git a/packages/join-e2e/fake-api/server.js b/packages/join-e2e/fake-api/server.js index 89d9deb..6d88557 100644 --- a/packages/join-e2e/fake-api/server.js +++ b/packages/join-e2e/fake-api/server.js @@ -58,7 +58,32 @@ function parseFormEncoded(raw) { return result; } -/** A tiny in-memory Stripe: enough for the plugin's product and price set-up. */ +/** + * A Stripe list, filtered as Stripe filters it for the parameters the plugin + * sends: plain fields (?customer=cus_1), ranges (?created[gte]=...), and + * subscriptions' status, where "all" means any and leaving it out means any + * but canceled. + */ +function listStripe(type, store, url) { + const ignored = ['limit', 'starting_after', 'ending_before']; + const params = [...url.searchParams].filter(([key]) => !ignored.includes(key) && !key.startsWith('expand')); + const ranges = { gt: (a, b) => a > b, gte: (a, b) => a >= b, lt: (a, b) => a < b, lte: (a, b) => a <= b }; + const hasStatus = params.some(([key]) => key === 'status'); + return store.filter((object) => { + if (type === 'subscriptions' && !hasStatus && object.status === 'canceled') return false; + return params.every(([key, value]) => { + const range = key.match(/^([a-z_]+)\[(gt|gte|lt|lte)\]$/); + if (range) return ranges[range[2]](Number(object[range[1]]), Number(value)); + if (key === 'status' && value === 'all') return true; + return String(object[key]) === value; + }); + }); +} + +/** + * A tiny in-memory Stripe: enough for the plugin's product and price set-up, + * and for the customer and subscription look-ups its webhook handlers make. + */ function fakeStripe(method, url, body) { const match = url.pathname.match(/^\/v1\/([a-z_]+)(?:\/([^/]+))?$/); if (!match) return null; @@ -83,6 +108,9 @@ function fakeStripe(method, url, body) { ); return { status: 200, body: { object: 'search_result', url: url.pathname, has_more: false, data } }; } + if (method === 'GET' && !idOrAction) { + return { status: 200, body: { object: 'list', url: url.pathname, has_more: false, data: listStripe(type, store, url) } }; + } if (method === 'POST' && !idOrAction) { stripe.counter += 1; const object = { id: `${prefix}_e2e_${stripe.counter}`, object: type.replace(/s$/, ''), active: true, livemode: false, ...body }; @@ -171,6 +199,14 @@ function handleControl(request, response) { overrides.unshift(rule); return send(200, { ok: true }); } + // Puts an object straight into the fake Stripe, e.g. POST /stripe/customers + // with {"id": "cus_1", "email": "..."}, for tests that need one to exist. + const stripeSeed = request.url.match(/^\/stripe\/([a-z_]+)$/); + if (request.method === 'POST' && stripeSeed && stripe.objects[stripeSeed[1]]) { + const object = JSON.parse(Buffer.concat(chunks).toString('utf8')); + stripe.objects[stripeSeed[1]].push(object); + return send(200, object); + } if (request.method === 'GET' && request.url === '/health') return send(200, { ok: true }); send(404, { error: 'unknown control endpoint' }); }); diff --git a/packages/join-e2e/tests/settings/fake-api.ts b/packages/join-e2e/tests/settings/fake-api.ts index 8cdabc8..5f0e20f 100644 --- a/packages/join-e2e/tests/settings/fake-api.ts +++ b/packages/join-e2e/tests/settings/fake-api.ts @@ -51,3 +51,11 @@ export async function overrideFakeApi(rule: { }): Promise { await control('POST', '/overrides', rule); } + +/** + * Puts an object straight into the fake Stripe, as if it had been created + * there earlier, e.g. seedFakeStripe('customers', { id: 'cus_1', email: '...' }). + */ +export async function seedFakeStripe(type: 'products' | 'prices' | 'customers' | 'subscriptions', object: Record): Promise { + await control('POST', `/stripe/${type}`, object); +} diff --git a/packages/join-e2e/tests/settings/legacy/README.md b/packages/join-e2e/tests/settings/legacy/README.md index 2901bd0..29aef92 100644 --- a/packages/join-e2e/tests/settings/legacy/README.md +++ b/packages/join-e2e/tests/settings/legacy/README.md @@ -3,7 +3,16 @@ Raw `wp_options` rows exactly as the settings page stored them while it was built with Carbon Fields (up to plugin version 1.4.x): one option per field, named after the field with a leading underscore, checkboxes stored as `"yes"` -or `""`. +or `""`. Complex fields such as the membership plans are spread over one +option per sub-field and row (`_membership_plans|label|0|0|value`). + +`membership-plans.json` also holds the copy of each plan the plugin stores +under its ID when the settings are saved (`ck_join_flow_membership_plan_`). +Those are written as arrays, as WordPress stores them. "Legacy Standard", which +has no Price Point ID, is stored under its name alone +(`ck_join_flow_membership_plan_legacy-standard`), as sites last saved before +plugin version 1.4.4 have it. The plugin finds it by working out each stored +plan's ID. The `legacy` tests in each settings spec load one of these files straight into the database and check the plugin still reads the settings and the join form diff --git a/packages/join-e2e/tests/settings/legacy/membership-plans.json b/packages/join-e2e/tests/settings/legacy/membership-plans.json new file mode 100644 index 0000000..a30dd2d --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/membership-plans.json @@ -0,0 +1,53 @@ +{ + "_lapsed_tag": "Legacy lapsed", + "_cancelled_tag": "Legacy cancelled", + "_lapsing_tag": "Legacy lapsing", + "_membership_plans|||0|value": "_", + "_membership_plans|||1|value": "_", + "_membership_plans|label|0|0|value": "Legacy Standard", + "_membership_plans|id|0|0|value": "", + "_membership_plans|amount|0|0|value": "6", + "_membership_plans|allow_custom_amount|0|0|value": "", + "_membership_plans|frequency|0|0|value": "monthly", + "_membership_plans|currency|0|0|value": "GBP", + "_membership_plans|description|0|0|value": "The usual", + "_membership_plans|add_tags|0|0|value": "Legacy member", + "_membership_plans|redirect_instead_of_payment|0|0|value": "", + "_membership_plans|redirect_page|0|0|_empty": "", + "_membership_plans|label|1|0|value": "Legacy Pay What You Can", + "_membership_plans|id|1|0|value": "legacy-pwyc", + "_membership_plans|amount|1|0|value": "3", + "_membership_plans|allow_custom_amount|1|0|value": "yes", + "_membership_plans|frequency|1|0|value": "yearly", + "_membership_plans|currency|1|0|value": "EUR", + "_membership_plans|description|1|0|value": "", + "_membership_plans|add_tags|1|0|value": "", + "_membership_plans|redirect_instead_of_payment|1|0|value": "", + "_membership_plans|redirect_page|1|0|_empty": "", + "ck_join_flow_membership_plan_legacy-standard": { + "_type": "_", + "label": "Legacy Standard", + "id": "", + "amount": "6", + "allow_custom_amount": false, + "frequency": "monthly", + "currency": "GBP", + "description": "The usual", + "add_tags": "Legacy member", + "redirect_instead_of_payment": false, + "redirect_page": [] + }, + "ck_join_flow_membership_plan_legacy-pwyc": { + "_type": "_", + "label": "Legacy Pay What You Can", + "id": "legacy-pwyc", + "amount": "3", + "allow_custom_amount": true, + "frequency": "yearly", + "currency": "EUR", + "description": "", + "add_tags": "", + "redirect_instead_of_payment": false, + "redirect_page": [] + } +} diff --git a/packages/join-e2e/tests/settings/membership-plans.spec.ts b/packages/join-e2e/tests/settings/membership-plans.spec.ts new file mode 100644 index 0000000..0f57aa3 --- /dev/null +++ b/packages/join-e2e/tests/settings/membership-plans.spec.ts @@ -0,0 +1,550 @@ +import { Page } from '@playwright/test'; +import { createHash } from 'crypto'; +import { CONTINUE } from '../helpers'; +import { + GLOBAL_PLANS_PAGE, + JoinSettingsPage, + MEMBER_EMAIL, + expect, + fakeApiRequests, + getSettings, + joinFormEnv, + loadLegacySettings, + openJoinForm, + postJoin, + postStripeWebhook, + seedFakeStripe, + test, + wpEvalJson, +} from './fixtures'; + +/** + * Settings page, Membership Plans tab: the global membership plans, and the + * tags members get in Action Network, Mailchimp and Zetkin when their + * membership lapses, is cancelled, or starts failing. + * + * Global plans are offered by join forms that have no plans of their own + * (GLOBAL_PLANS_PAGE). On save, each plan is also stored on its own, under + * its ID, which is how the rest of the plugin finds a member's plan when they + * join, and set up in Stripe when Stripe is on. + * + * The tag effects are checked in the requests the plugin makes to a fake + * Mailchimp (the three CRMs share the tag logic, see JoinService). + */ + +const MAILCHIMP = 'us1.api.mailchimp.com'; +/** Mailchimp addresses a member by the MD5 of their lower-cased email. */ +const MEMBER_TAGS_PATH = `/3.0/lists/e2e-audience/members/${createHash('md5').update(MEMBER_EMAIL.toLowerCase()).digest('hex')}/tags`; + +/** Plans as stored under their IDs (ck_join_flow_membership_plan_), false for none, in one WP-CLI call. */ +const storedPlans = (ids: string[]) => + wpEvalJson | false>>(` + $plans = []; + foreach (json_decode(${JSON.stringify(JSON.stringify(ids))}) as $id) { + $plans[$id] = get_option('ck_join_flow_membership_plan_' . $id); + } + return $plans; + `); + +/** A plan as stored under its ID, or false. */ +const storedPlan = (id: string) => storedPlans([id])[id]; + +/** The plans the join form on GLOBAL_PLANS_PAGE is given. */ +const formPlans = async (page: Page) => (await joinFormEnv(page, GLOBAL_PLANS_PAGE)).MEMBERSHIP_PLANS; + +/** Opens the join form on GLOBAL_PLANS_PAGE and moves on to choosing a plan. */ +async function openPlanStep(page: Page): Promise { + await openJoinForm(page, GLOBAL_PLANS_PAGE); + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); +} + +/** A plan's panel on the join form's plan step. */ +const planPanel = (page: Page, label: string) => + page.locator('label.radio-panel', { has: page.locator(`[id="membership-${label}"]`) }); + +/** The tag changes the plugin has sent to the fake Mailchimp, in order, e.g. ['Lapsing: active']. */ +async function mailchimpTagChanges(): Promise { + return (await fakeApiRequests(MAILCHIMP)) + .filter((request) => request.method === 'POST' && request.path === MEMBER_TAGS_PATH) + .flatMap((request) => request.body.tags.map((tag: { name: string; status: string }) => `${tag.name}: ${tag.status}`)); +} + +test.describe('Global membership plans', () => { + test('a new plan is saved, stored under its ID, and offered on join forms without plans of their own', async ({ + settings, + page, + }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ + name: 'Supporter', + price: '12', + frequency: 'yearly', + currency: 'EUR', + description: 'For people who back the union', + addTags: 'Supporter, Yearly', + }); + await settings.save(); + + const plans = getSettings(['membership_plans']).membership_plans as Record[]; + expect(plans.map((plan) => plan.label)).toEqual(['Baseline Plan', 'Supporter']); + expect(plans[1]).toMatchObject({ + label: 'Supporter', + id: '', + amount: '12', + allow_custom_amount: false, + frequency: 'yearly', + currency: 'EUR', + description: 'For people who back the union', + add_tags: 'Supporter, Yearly', + }); + // With no Price Point ID, the ID is made from the name, frequency and currency. + const stored = storedPlans(['supporter_yearly_eur', 'baseline-plan_monthly_gbp']); + expect(stored['supporter_yearly_eur']).toMatchObject({ label: 'Supporter', amount: '12', currency: 'EUR' }); + expect(stored['baseline-plan_monthly_gbp']).toMatchObject({ label: 'Baseline Plan', amount: '7' }); + + expect(await formPlans(page)).toEqual([ + expect.objectContaining({ value: 'baseline-plan_monthly_gbp', label: 'Baseline Plan' }), + { + value: 'supporter_yearly_eur', + label: 'Supporter', + allowCustomAmount: false, + amount: '12', + currency: 'EUR', + frequency: 'yearly', + description: 'For people who back the union', + redirectUrl: null, + }, + ]); + + await openPlanStep(page); + const supporter = planPanel(page, 'Supporter'); + await expect(supporter).toContainText('€12, yearly'); + await expect(supporter).toContainText('For people who back the union'); + await expect(planPanel(page, 'Baseline Plan')).toContainText('£7, monthly'); + await supporter.click(); + await expect(page.locator(CONTINUE)).toHaveText('Continue and pay €12 yearly'); + }); +}); + +test.describe('Membership plan fields', () => { + test('Price Point ID becomes the plan\'s ID', async ({ settings, page, request }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ name: 'Gold', id: 'gold-tier', price: '20' }); + await settings.save(); + + const stored = storedPlans(['gold-tier', 'gold_monthly_gbp']); + expect(stored['gold-tier']).toMatchObject({ label: 'Gold', id: 'gold-tier' }); + expect(stored['gold_monthly_gbp']).toBe(false); + expect((await formPlans(page)).map((plan: any) => plan.value)).toContain('gold-tier'); + + // Joins name the plan by this ID. + expect((await postJoin(request, { membership: 'gold-tier' })).ok()).toBe(true); + }); + + test('Frequency and Currency set how the price is shown', async ({ settings, page }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ name: 'Weekly', price: '2', frequency: 'weekly', currency: 'GBP' }); + await settings.addPlan({ name: 'Dollars', price: '9', frequency: 'monthly', currency: 'USD' }); + await settings.save(); + + await openPlanStep(page); + await expect(planPanel(page, 'Weekly')).toContainText('£2, weekly'); + await expect(planPanel(page, 'Dollars')).toContainText('$9, monthly'); + }); + + test('a new plan is monthly and in GBP unless changed', async ({ settings }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ name: 'Defaults', price: '4' }); + await settings.save(); + + expect(storedPlan('defaults_monthly_gbp')).toMatchObject({ frequency: 'monthly', currency: 'GBP' }); + }); + + test('Allow users to change the amount lets members pay more than the price, but not less', async ({ + settings, + page, + request, + }) => { + await settings.open('Membership Plans'); + await settings.editPlan(0, { allowCustomAmount: true }); + await settings.save(); + + expect(storedPlan('baseline-plan_monthly_gbp')).toMatchObject({ allow_custom_amount: true }); + + await openPlanStep(page); + const amount = page.locator('input[id="baseline-plan_monthly_gbp-amount"]'); + await expect(amount).toHaveAttribute('min', '7'); + await amount.fill('15'); + await expect(page.locator(CONTINUE)).toHaveText('Continue and pay £15 monthly'); + + // The plugin enforces the minimum too, not just the form. + expect((await postJoin(request, { membership: 'baseline-plan_monthly_gbp', customMembershipAmount: 5 })).status()).toBe(500); + expect((await postJoin(request, { membership: 'baseline-plan_monthly_gbp', customMembershipAmount: 15 })).ok()).toBe(true); + }); + + test('without it, the price is fixed', async ({ page }) => { + await openPlanStep(page); + await expect(page.locator('input[id="baseline-plan_monthly_gbp-amount"]')).toHaveCount(0); + await expect(planPanel(page, 'Baseline Plan')).toContainText('£7, monthly'); + }); +}); + +test.describe('Changing the plan list', () => { + test('a removed plan is no longer offered', async ({ settings, page, request }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ name: 'Replacement', price: '8' }); + await settings.save(); + expect(storedPlan('baseline-plan_monthly_gbp')).toMatchObject({ label: 'Baseline Plan' }); + + await settings.tab('Membership Plans'); + await settings.removePlan(0); + await settings.save(); + + expect((getSettings(['membership_plans']).membership_plans as any[]).map((plan) => plan.label)).toEqual([ + 'Replacement', + ]); + expect((await formPlans(page)).map((plan: any) => plan.label)).toEqual(['Replacement']); + + // Its stored copy is kept, so a join form that was opened before the plan + // was removed can still be completed. This pins today's behaviour, which + // also leaves the plan joinable for good: whether it should is Linear + // JOIN-170. + expect(storedPlan('baseline-plan_monthly_gbp')).toMatchObject({ label: 'Baseline Plan' }); + expect((await postJoin(request, { membership: 'baseline-plan_monthly_gbp' })).ok()).toBe(true); + }); + + test('a changed price reaches the join form and the stored plan', async ({ settings, page }) => { + await settings.open('Membership Plans'); + await settings.editPlan(0, { price: '9' }); + await settings.save(); + + expect(storedPlan('baseline-plan_monthly_gbp')).toMatchObject({ amount: '9' }); + await openPlanStep(page); + await expect(planPanel(page, 'Baseline Plan')).toContainText('£9, monthly'); + }); + + test('the page will not save without at least one plan, and nothing is saved', async ({ settings }) => { + await settings.open('Membership Plans'); + await settings.removePlan(0); + await settings.setText('Lapsed Tag', 'Not saved'); + + expect(await settings.saveExpectingErrors()).toEqual(['You must add at least one membership plan.']); + expect(getSettings(['lapsed_tag']).lapsed_tag).toBe('Lapsed - failed payment'); + expect((getSettings(['membership_plans']).membership_plans as any[]).map((plan) => plan.label)).toEqual([ + 'Baseline Plan', + ]); + }); + + for (const [missing, plan] of [ + ['Name', { price: '5' }], + ['Price', { name: 'No Price' }], + ] as const) { + test(`a plan without a ${missing} cannot be saved`, async ({ settings }) => { + await settings.open('Membership Plans'); + await settings.addPlan(plan); + await settings.expectSaveRefused(); + + expect((getSettings(['membership_plans']).membership_plans as any[]).map((saved) => saved.label)).toEqual([ + 'Baseline Plan', + ]); + }); + } + + test('a join naming a plan that was never saved is refused', async ({ request }) => { + // The same join on a stored plan (the seeded join form's) goes through, + // so the refusal is down to the plan. + expect((await postJoin(request, { membership: 'standard' })).ok()).toBe(true); + expect((await postJoin(request, { membership: 'no-such-plan' })).status()).toBe(500); + }); + + // Only the page checks a plan has a Name and Price; the plugin saves one + // without (Linear JOIN-169). Re-enable once it's fixed. + test.fixme('the plugin refuses a plan without a Name, even when the page\'s own checks are skipped', async ({ + settings, + }) => { + await settings.open('Membership Plans'); + await settings.addPlan({ price: '5' }); + + expect(await settings.saveSkippingPageChecksExpectingErrors()).not.toEqual([]); + expect((getSettings(['membership_plans']).membership_plans as any[]).map((saved) => saved.label)).toEqual([ + 'Baseline Plan', + ]); + }); +}); + +test.describe('Global plans in Stripe', () => { + test('each plan gets a Stripe product, and a recurring price in its currency, amount and frequency', async ({ + settings, + }) => { + await settings.open('Membership Plans'); + await settings.editPlan(0, { frequency: 'weekly', price: '2' }); + await settings.addPlan({ name: 'Dollars', price: '9.50', currency: 'USD', description: 'Paid in dollars' }); + await settings.addPlan({ name: 'Euros', price: '60', frequency: 'yearly', currency: 'EUR' }); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + + const created = (await fakeApiRequests('api.stripe.com')).filter((request) => request.method === 'POST'); + const products = created.filter((request) => request.path === '/v1/products').map((request) => request.body); + const prices = created.filter((request) => request.path === '/v1/prices').map((request) => request.body); + + expect(products).toEqual([ + expect.objectContaining({ name: 'Membership: Baseline Plan', metadata: { membership_plan: 'baseline-plan_weekly_gbp' } }), + expect.objectContaining({ + name: 'Membership: Dollars', + description: 'Paid in dollars', + metadata: { membership_plan: 'dollars_monthly_usd' }, + }), + expect.objectContaining({ name: 'Membership: Euros', metadata: { membership_plan: 'euros_yearly_eur' } }), + ]); + expect(products[0]).not.toHaveProperty('description'); + expect(prices).toEqual([ + expect.objectContaining({ unit_amount: '200', currency: 'GBP', recurring: { interval: 'week' } }), + expect.objectContaining({ unit_amount: '950', currency: 'USD', recurring: { interval: 'month' } }), + expect.objectContaining({ unit_amount: '6000', currency: 'EUR', recurring: { interval: 'year' } }), + ]); + + // Each stored plan remembers its Stripe product and price. + const stored = storedPlans(['baseline-plan_weekly_gbp', 'dollars_monthly_usd', 'euros_yearly_eur']); + for (const plan of Object.values(stored)) { + expect(plan).toMatchObject({ + stripe_product_id: expect.stringMatching(/^prod_/), + stripe_price_id: expect.stringMatching(/^price_/), + }); + } + }); + + test('a changed price gets a new Stripe price on the same product', async ({ settings }) => { + await settings.open('Membership Plans'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + const before = storedPlan('baseline-plan_monthly_gbp') as Record; + + await settings.tab('Membership Plans'); + await settings.editPlan(0, { price: '8' }); + await settings.save(); + + const after = storedPlan('baseline-plan_monthly_gbp') as Record; + expect(after.stripe_product_id).toBe(before.stripe_product_id); + expect(after.stripe_price_id).not.toBe(before.stripe_price_id); + const prices = (await fakeApiRequests('api.stripe.com')).filter( + (request) => request.method === 'POST' && request.path === '/v1/prices', + ); + expect(prices.map((request) => request.body.unit_amount)).toEqual(['700', '800']); + }); + + // Each save makes another Stripe price for a plan whose price has pence in + // it (Linear JOIN-166). Re-enable once it's fixed. + test.fixme('saving again reuses the Stripe price of a plan priced in pence', async ({ settings }) => { + await settings.open('Membership Plans'); + await settings.editPlan(0, { price: '9.50' }); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + await settings.save(); + + const prices = (await fakeApiRequests('api.stripe.com')).filter( + (request) => request.method === 'POST' && request.path === '/v1/prices', + ); + expect(prices.map((request) => request.body.unit_amount)).toEqual(['950']); + }); +}); + +test.describe('Member tags', () => { + const CUSTOMER = { id: 'cus_member', object: 'customer', email: MEMBER_EMAIL }; + + /** Turns on Stripe (whose webhooks report lapses) and Mailchimp, and sets the three tags. Does not save. */ + async function setUpTagging( + settings: JoinSettingsPage, + tags: { lapsed?: string; cancelled?: string; lapsing?: string } = {}, + ): Promise { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.enableIntegration('Use Mailchimp'); + await settings.tab('Membership Plans'); + await settings.setText('Lapsed Tag', tags.lapsed ?? 'Lapsed'); + await settings.setText('Cancelled Tag', tags.cancelled ?? 'Left'); + await settings.setText('Lapsing Tag', tags.lapsing ?? 'Payment trouble'); + } + + const subscriptionDeleted = (reason: string) => ({ + object: { id: 'sub_old', object: 'subscription', customer: CUSTOMER.id, status: 'canceled', cancellation_details: { reason } }, + }); + + test('have defaults', () => { + expect(getSettings(['lapsed_tag', 'cancelled_tag', 'lapsing_tag'])).toEqual({ + lapsed_tag: 'Lapsed - failed payment', + cancelled_tag: 'Cancelled', + lapsing_tag: 'Lapsing', + }); + }); + + test('joining adds the plan\'s Add Tags, and takes off the other plans\' tags and the lapsed, cancelled and lapsing tags', async ({ + settings, + request, + }) => { + await setUpTagging(settings); + await settings.editPlan(0, { addTags: 'Member, Monthly' }); + await settings.addPlan({ name: 'Other', price: '3', addTags: 'Other Tier' }); + await settings.tab('Features'); + // Stripe would want a paid subscription to check the join against. + await settings.setCheckbox('Use Stripe', false); + await settings.save(); + + expect(getSettings(['lapsed_tag', 'cancelled_tag', 'lapsing_tag'])).toEqual({ + lapsed_tag: 'Lapsed', + cancelled_tag: 'Left', + lapsing_tag: 'Payment trouble', + }); + + expect((await postJoin(request, { membership: 'baseline-plan_monthly_gbp' })).ok()).toBe(true); + + const signup = (await fakeApiRequests(MAILCHIMP)).find( + (request) => request.method === 'POST' && request.path === '/3.0/lists/e2e-audience/members', + ); + expect(signup?.body).toMatchObject({ email_address: MEMBER_EMAIL, tags: ['Member', 'Monthly'] }); + expect(await mailchimpTagChanges()).toEqual([ + 'Lapsed: inactive', + 'Left: inactive', + 'Payment trouble: inactive', + 'Other Tier: inactive', + ]); + }); + + test('a subscription ended by failed payments gets the Lapsed Tag', async ({ settings, request }) => { + await setUpTagging(settings); + await settings.save(); + await seedFakeStripe('customers', CUSTOMER); + + await postStripeWebhook(request, 'customer.subscription.deleted', subscriptionDeleted('payment_failed')); + + expect(await mailchimpTagChanges()).toEqual(['Lapsed: active', 'Left: inactive', 'Payment trouble: inactive']); + }); + + test('a member with another active subscription is not tagged when one ends', async ({ settings, request }) => { + await setUpTagging(settings); + await settings.save(); + await seedFakeStripe('customers', CUSTOMER); + // For example the replacement /join creates when a member changes plan. + await seedFakeStripe('subscriptions', { id: 'sub_new', object: 'subscription', customer: CUSTOMER.id, status: 'active' }); + + await postStripeWebhook(request, 'customer.subscription.deleted', subscriptionDeleted('cancellation_requested')); + + // It did look, so it's the other subscription that stopped the tagging. + expect((await fakeApiRequests('api.stripe.com')).some((request) => request.path === '/v1/subscriptions')).toBe(true); + expect(await mailchimpTagChanges()).toEqual([]); + }); + + test('a subscription the member cancelled gets the Cancelled Tag', async ({ settings, request }) => { + await setUpTagging(settings); + await settings.save(); + await seedFakeStripe('customers', CUSTOMER); + + await postStripeWebhook(request, 'customer.subscription.deleted', subscriptionDeleted('cancellation_requested')); + + expect(await mailchimpTagChanges()).toEqual(['Left: active', 'Lapsed: inactive', 'Payment trouble: inactive']); + }); + + test('a failed payment that Stripe will retry gets the Lapsing Tag', async ({ settings, request }) => { + await setUpTagging(settings); + await settings.save(); + await seedFakeStripe('customers', CUSTOMER); + + await postStripeWebhook(request, 'invoice.payment_failed', { + object: { + id: 'in_1', + object: 'invoice', + customer: CUSTOMER.id, + billing_reason: 'subscription_cycle', + next_payment_attempt: Math.floor(Date.now() / 1000) + 86400, + }, + }); + + expect(await mailchimpTagChanges()).toEqual(['Payment trouble: active']); + }); + + test('a blank Lapsed Tag means lapsed members are not tagged', async ({ settings, request }) => { + await setUpTagging(settings, { lapsed: '' }); + await settings.save(); + expect(getSettings(['lapsed_tag']).lapsed_tag).toBe(''); + await seedFakeStripe('customers', CUSTOMER); + + await postStripeWebhook(request, 'customer.subscription.deleted', subscriptionDeleted('payment_failed')); + + expect(await mailchimpTagChanges()).toEqual(['Left: inactive', 'Payment trouble: inactive']); + }); + + test('moving to another plan in Stripe swaps the old plan\'s Add Tags for the new one\'s', async ({ + settings, + request, + }) => { + await setUpTagging(settings); + await settings.editPlan(0, { addTags: 'Member, Monthly' }); + await settings.addPlan({ name: 'Gold', price: '20', addTags: 'Member, Gold' }); + await settings.save(); + await seedFakeStripe('customers', CUSTOMER); + + const stored = storedPlans(['baseline-plan_monthly_gbp', 'gold_monthly_gbp']) as Record>; + const oldPrice = stored['baseline-plan_monthly_gbp'].stripe_price_id; + const newPrice = stored['gold_monthly_gbp'].stripe_price_id; + await postStripeWebhook(request, 'customer.subscription.updated', { + object: { + id: 'sub_1', + object: 'subscription', + customer: CUSTOMER.id, + status: 'active', + items: { data: [{ price: { id: newPrice } }] }, + }, + previous_attributes: { items: { data: [{ price: { id: oldPrice } }] } }, + }); + + expect(await mailchimpTagChanges()).toEqual(['Member: active', 'Gold: active', 'Monthly: inactive']); + }); +}); + +test.describe('Membership plans saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. + test.beforeEach(() => loadLegacySettings('membership-plans')); + + test('are still read', () => { + const saved = getSettings(['membership_plans', 'lapsed_tag', 'cancelled_tag', 'lapsing_tag']); + expect(saved.membership_plans).toEqual([ + expect.objectContaining({ + label: 'Legacy Standard', + id: '', + amount: '6', + allow_custom_amount: false, + frequency: 'monthly', + currency: 'GBP', + description: 'The usual', + add_tags: 'Legacy member', + }), + expect.objectContaining({ + label: 'Legacy Pay What You Can', + id: 'legacy-pwyc', + amount: '3', + allow_custom_amount: true, + frequency: 'yearly', + currency: 'EUR', + }), + ]); + expect(saved).toMatchObject({ + lapsed_tag: 'Legacy lapsed', + cancelled_tag: 'Legacy cancelled', + lapsing_tag: 'Legacy lapsing', + }); + }); + + test('are still offered on the join form', async ({ page }) => { + await openPlanStep(page); + await expect(planPanel(page, 'Legacy Standard')).toContainText('£6, monthly'); + await expect(planPanel(page, 'Legacy Standard')).toContainText('The usual'); + await planPanel(page, 'Legacy Pay What You Can').click(); + await expect(page.locator('input[id="legacy-pwyc-amount"]')).toHaveAttribute('min', '3'); + }); + + test('can still be joined on', async ({ request }) => { + expect((await postJoin(request, { membership: 'legacy-standard_monthly_gbp' })).ok()).toBe(true); + expect((await postJoin(request, { membership: 'legacy-pwyc', customMembershipAmount: 2 })).status()).toBe(500); + expect((await postJoin(request, { membership: 'legacy-pwyc', customMembershipAmount: 4 })).ok()).toBe(true); + }); +}); diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 16e7799..2709f93 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -1,4 +1,4 @@ -import { Locator, Page, expect } from '@playwright/test'; +import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; import { execFileSync } from 'child_process'; import { readFileSync } from 'fs'; import path from 'path'; @@ -31,6 +31,8 @@ export const FREE_JOIN_PAGE = '/e2e-free-join/'; export const CUSTOM_FIELDS_PAGE = '/e2e-custom-fields/'; /** A join form offering the same plan in GBP and EUR. */ export const MULTI_CURRENCY_PAGE = '/e2e-multi-currency-join/'; +/** A join form with no plans of its own, so it offers the global plans. */ +export const GLOBAL_PLANS_PAGE = '/e2e-global-plans-join/'; /** A pattern matching exactly `text`, with any pattern characters in it escaped. */ const exactly = (text: string) => new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); @@ -135,6 +137,26 @@ export async function joinFormEnv(page: Page, url = STANDARD_JOIN_PAGE): Promise return JSON.parse((match as RegExpMatchArray)[2]); } +/** + * Sends a join to the plugin's /join endpoint, as the join form does at its + * last step, and returns the response. Only the fields the test cares about + * need passing; `membership` is the chosen plan's ID. + */ +/** The email postJoin() joins with, unless given another. */ +export const MEMBER_EMAIL = 'member@example.org'; + +export async function postJoin(request: APIRequestContext, data: Record) { + return request.post('/wp-json/join/v1/join', { + data: { firstName: 'Ada', lastName: 'Lovelace', email: MEMBER_EMAIL, ...data }, + }); +} + +/** Delivers an event to the plugin's Stripe webhook endpoint, as Stripe would. */ +export async function postStripeWebhook(request: APIRequestContext, type: string, data: Record) { + const response = await request.post('/wp-json/join/v1/stripe/webhook', { data: { type, data } }); + expect(response.ok()).toBe(true); +} + /** * Integration switches on the Features tab, and the Integrations tab * credentials each one needs before the settings page will save it switched @@ -163,6 +185,20 @@ export const INTEGRATION_CREDENTIALS: Record> = { 'Use Mailchimp': { 'Mailchimp API key': 'e2e-mailchimp-key-us1', 'Mailchimp audience ID': 'e2e-audience' }, }; +/** A membership plan's fields on the settings page. Unset fields are left as they are. */ +export type PlanFields = { + name?: string; + /** "Price Point ID". */ + id?: string; + price?: string; + allowCustomAmount?: boolean; + frequency?: 'yearly' | 'monthly' | 'weekly'; + currency?: 'GBP' | 'EUR' | 'USD'; + description?: string; + /** Comma-separated. */ + addTags?: string; +}; + /** * The WP Admin > Join settings page. * @@ -243,6 +279,57 @@ export class JoinSettingsPage { await this.setCheckbox(switchLabel, true); } + /** The global membership plans on the Membership Plans tab, one per plan. */ + plans(): Locator { + return this.plansField().locator('.cf-complex__group'); + } + + private plansField(): Locator { + return this.page + .locator('.cf-container__fields .cf-field.cf-complex') + .filter({ has: this.page.locator('.cf-field__label', { hasText: /^Membership Plans$/ }) }); + } + + /** Adds a membership plan at the end of the list and fills it in. Does not save. */ + async addPlan(plan: PlanFields): Promise { + const count = await this.plans().count(); + await this.plansField().locator('.cf-complex__inserter-button').click(); + await expect(this.plans()).toHaveCount(count + 1); + await this.editPlan(count, plan); + } + + /** Changes the given fields of the plan at `index` (0 is the first). Does not save. */ + async editPlan(index: number, plan: PlanFields): Promise { + const group = this.plans().nth(index); + if (await group.evaluate((element) => element.classList.contains('cf-complex__group--collapsed'))) { + await group.locator('.cf-complex__group-head').click(); + } + const input = (label: string) => + group + .locator(`.cf-field:has(.cf-field__label:text-matches("^${label}[*]?$"))`) + .first() + .locator('input:not([type="hidden"]), select, textarea') + .first(); + + if (plan.name !== undefined) await input('Name').fill(plan.name); + if (plan.id !== undefined) await input('Price Point ID').fill(plan.id); + if (plan.price !== undefined) await input('Price').fill(plan.price); + if (plan.allowCustomAmount !== undefined) { + await group.getByLabel('Allow users to change the amount', { exact: true }).setChecked(plan.allowCustomAmount); + } + if (plan.frequency !== undefined) await input('Frequency').selectOption(plan.frequency); + if (plan.currency !== undefined) await input('Currency').selectOption(plan.currency); + if (plan.description !== undefined) await input('Description').fill(plan.description); + if (plan.addTags !== undefined) await input('Add Tags').fill(plan.addTags); + } + + /** Removes the plan at `index` (0 is the first). Does not save. */ + async removePlan(index: number): Promise { + const count = await this.plans().count(); + await this.plans().nth(index).locator('button[title="Remove"]').click(); + await expect(this.plans()).toHaveCount(count - 1); + } + /** Saves every tab, and fails with the page's error messages if the save is rejected. */ async save(): Promise { await this.submit(); @@ -259,6 +346,55 @@ export class JoinSettingsPage { return errors; } + /** + * Checks the page will not save as it stands. It may not let the save be + * made at all (today Save is disabled while a required field is empty; a + * rebuilt page may leave it to the browser to block a field breaking its + * own rules), or it may make the save and reject it with an error. + * + * A save refused before it is made says nothing about the plugin's own + * checks: test those with saveSkippingPageChecksExpectingErrors(). + */ + async expectSaveRefused(): Promise { + const publish = this.page.locator('#publish'); + // Give the page a moment to disable Save after the last change. + const disabled = await expect + .poll(() => publish.isDisabled(), { timeout: 2000 }) + .toBe(true) + .then( + () => true, + () => false, + ); + if (disabled) { + return; + } + const navigated = await Promise.all([ + this.page.waitForNavigation({ timeout: 5000 }).then( + () => true, + () => false, + ), + publish.click(), + ]).then(([didNavigate]) => didNavigate); + if (!navigated) { + // The browser blocked the submit. + return; + } + expect(await this.errors(), 'expected the settings page to refuse the save').not.toEqual([]); + } + + /** + * Saves with the page's own checks turned off (Save enabled, the browser's + * field checks skipped), as a request made without the browser would be, + * expecting the plugin to reject it. Returns its error messages. + */ + async saveSkippingPageChecksExpectingErrors(): Promise { + await this.page.locator('#publish').evaluate((button: HTMLInputElement) => { + button.disabled = false; + if (button.form) button.form.noValidate = true; + }); + return this.saveExpectingErrors(); + } + private async submit(): Promise { await Promise.all([this.page.waitForNavigation(), this.page.locator('#publish').click()]); } diff --git a/readme.md b/readme.md index c8997b4..f1f73b3 100644 --- a/readme.md +++ b/readme.md @@ -505,7 +505,7 @@ How the tests are put together: The `fake-api` container (`packages/join-e2e/fake-api/server.js`) stands in for every service the plugin calls: Stripe, Mailchimp, Action Network, Zetkin, GoCardless, Chargebee, Auth0 and postcode lookups. Inside the Docker network, their hostnames (`api.stripe.com` and so on) resolve to it. It answers over HTTPS with a test certificate that only the E2E WordPress containers trust, so the plugin's real code and SDKs run unchanged and nothing reaches the real services. -It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them. +It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them, and lists and looks up customers and subscriptions. Tests can put a Stripe object in place first (`seedFakeStripe`), e.g. the customer a webhook event names. To fake another hostname, add it to `fake-api/certs/san.cnf`, run `fake-api/certs/generate.sh`, and add it to the `fake-api` network aliases in `docker-compose.yml`.