From 6873e27555db07df9c09897a167a9079017c31c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:49:52 +0000 Subject: [PATCH 1/2] Add settings page regression tests for the Membership Plans tab Covers the global membership plans and the lapsed, cancelled and lapsing tags: each setting is saved, read back through Settings::get, has its effect on the join form, on joins and in Stripe and Mailchimp (fake API), and is still read when loaded as Carbon Fields stored it. The fake Stripe now lists objects and can have objects put in place by a test, so the plugin's webhook handlers can be driven end to end. Duplicate Stripe prices for prices with pence (JOIN-166) are a skipped test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 2 +- packages/join-e2e/fake-api/server.js | 21 +- packages/join-e2e/tests/settings/fake-api.ts | 8 + .../join-e2e/tests/settings/legacy/README.md | 7 +- .../settings/legacy/membership-plans.json | 53 ++ .../tests/settings/membership-plans.spec.ts | 503 ++++++++++++++++++ packages/join-e2e/tests/settings/support.ts | 95 +++- readme.md | 2 +- 8 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 packages/join-e2e/tests/settings/legacy/membership-plans.json create mode 100644 packages/join-e2e/tests/settings/membership-plans.spec.ts diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index a061a8c..8297bae 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -77,7 +77,7 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending | 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); Name, Price and at least one plan required; 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 (fake Mailchimp). Known problem: JOIN-166 (duplicate Stripe prices for prices with pence). | | 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: defaults and fallbacks, 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 766a3b1..7c13f41 100644 --- a/packages/join-e2e/fake-api/server.js +++ b/packages/join-e2e/fake-api/server.js @@ -58,7 +58,10 @@ function parseFormEncoded(raw) { return result; } -/** A tiny in-memory Stripe: enough for the plugin's product and price set-up. */ +/** + * 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 +86,14 @@ function fakeStripe(method, url, body) { ); return { status: 200, body: { object: 'search_result', url: url.pathname, has_more: false, data } }; } + if (method === 'GET' && !idOrAction) { + // List, filtered on plain fields, e.g. ?customer=cus_1&status=active + const filters = Object.entries(Object.fromEntries(url.searchParams)).filter( + ([key]) => !['limit', 'starting_after', 'ending_before'].includes(key) && !key.startsWith('expand'), + ); + const data = store.filter((object) => filters.every(([key, value]) => String(object[key]) === value)); + return { status: 200, body: { object: 'list', url: url.pathname, has_more: false, data } }; + } if (method === 'POST' && !idOrAction) { stripe.counter += 1; const object = { id: `${prefix}_e2e_${stripe.counter}`, object: type.replace(/s$/, ''), active: true, livemode: false, ...body }; @@ -154,6 +165,14 @@ function handleControl(request, response) { overrides.unshift(JSON.parse(Buffer.concat(chunks).toString('utf8'))); 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..1131cbf 100644 --- a/packages/join-e2e/tests/settings/legacy/README.md +++ b/packages/join-e2e/tests/settings/legacy/README.md @@ -3,7 +3,12 @@ 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. 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..d1fbc20 --- /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_monthly_gbp": { + "_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..0511e2e --- /dev/null +++ b/packages/join-e2e/tests/settings/membership-plans.spec.ts @@ -0,0 +1,503 @@ +import { Page } from '@playwright/test'; +import { CONTINUE } from '../helpers'; +import { + GLOBAL_PLANS_PAGE, + JoinSettingsPage, + 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'; +const MEMBER_EMAIL = 'member@example.org'; +/** Mailchimp addresses a member by the MD5 of their lower-cased email. */ +const MEMBER_TAGS_PATH = '/3.0/lists/e2e-audience/members/adf3c55fec42518eefba5901a7ca837f/tags'; + +/** A plan as stored under its ID (ck_join_flow_membership_plan_), or false. */ +const storedPlan = (id: string) => + wpEvalJson | false>(`return get_option(${JSON.stringify(`ck_join_flow_membership_plan_${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. + expect(storedPlan('supporter_yearly_eur')).toMatchObject({ label: 'Supporter', amount: '12', currency: 'EUR' }); + expect(storedPlan('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(); + + expect(storedPlan('gold-tier')).toMatchObject({ label: 'Gold', id: 'gold-tier' }); + expect(storedPlan('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. + 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 }) => { + expect((await postJoin(request, { membership: 'no-such-plan' })).status()).toBe(500); + }); +}); + +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. + for (const id of ['baseline-plan_weekly_gbp', 'dollars_monthly_usd', 'euros_yearly_eur']) { + expect(storedPlan(id)).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 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 oldPrice = (storedPlan('baseline-plan_monthly_gbp') as Record).stripe_price_id; + const newPrice = (storedPlan('gold_monthly_gbp') as Record).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 0baeae7..58d46b8 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'; @@ -25,6 +25,8 @@ const OPTIONS_SNAPSHOT = '/var/www/html/wp-content/e2e-options-snapshot.sql'; export const STANDARD_JOIN_PAGE = '/e2e-standard-join/'; export const FREE_JOIN_PAGE = '/e2e-free-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/'; /** Runs WP-CLI against the E2E site and returns its standard output. */ export function wp(...args: string[]): string { @@ -118,6 +120,23 @@ export async function joinFormEnv(page: Page, url = STANDARD_JOIN_PAGE): Promise return JSON.parse((match as RegExpMatchArray)[1]); } +/** + * 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. + */ +export async function postJoin(request: APIRequestContext, data: Record) { + return request.post('/wp-json/join/v1/join', { + data: { firstName: 'Ada', lastName: 'Lovelace', email: 'member@example.org', ...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 @@ -146,6 +165,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. * @@ -226,6 +259,54 @@ export class JoinSettingsPage { await this.setCheckbox(switchLabel, true); } + /** The global membership plans on the Membership Plans tab, one per plan. */ + plans(): Locator { + return this.page + .locator('.cf-container__fields .cf-field.cf-complex') + .filter({ has: this.page.locator('.cf-field__label', { hasText: /^Membership Plans$/ }) }) + .locator('.cf-complex__group'); + } + + /** 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.page.locator('.cf-container__fields .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(); @@ -242,6 +323,18 @@ export class JoinSettingsPage { return errors; } + /** + * Checks the page will not save as it stands: either it won't let the save + * be made (today it disables Save while a required field is empty), or it + * rejects it with an error. + */ + async expectSaveRefused(): Promise { + if (await this.page.locator('#publish').isDisabled()) { + return; + } + await 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 334dedb..b54288e 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`. From 0e8a5747dbc21c528db46feaaf55a500b608a83b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:24:43 +0000 Subject: [PATCH 2/2] Address review on the Membership Plans tests - Fake Stripe lists filter as Stripe does for what the plugin sends: status=all, ranges such as created[gte], and no canceled subscriptions unless asked for. - expectSaveRefused tries the save when Save isn't disabled, so a page that leaves the check to the browser (native `required`) works too. saveSkippingPageChecksExpectingErrors tests the plugin's own checks; a skipped test shows it doesn't check a plan's Name (JOIN-169). - New test: a member with another active subscription isn't tagged when one ends. - The legacy fixture stores the plan without a Price Point ID under its name alone, as pre-1.4.4 sites have it, so the plugin's lookup for those is tested. - The "never saved" plan test pairs its 500 with a join that succeeds. - The removed-plan test notes it pins today's behaviour (JOIN-170). - addPlan uses the Membership Plans field's own Add button; stored plans are read in one WP-CLI call; the member's Mailchimp hash is worked out from a shared MEMBER_EMAIL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 2 +- packages/join-e2e/fake-api/server.js | 29 +++++-- .../join-e2e/tests/settings/legacy/README.md | 6 +- .../settings/legacy/membership-plans.json | 2 +- .../tests/settings/membership-plans.spec.ts | 75 +++++++++++++++---- packages/join-e2e/tests/settings/support.ts | 61 ++++++++++++--- 6 files changed, 143 insertions(+), 32 deletions(-) diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index b533040..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 | `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); Name, Price and at least one plan required; 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 (fake Mailchimp). Known problem: JOIN-166 (duplicate Stripe prices for prices with pence). | +| 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 0f0be1b..6d88557 100644 --- a/packages/join-e2e/fake-api/server.js +++ b/packages/join-e2e/fake-api/server.js @@ -58,6 +58,28 @@ function parseFormEncoded(raw) { return result; } +/** + * 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. @@ -87,12 +109,7 @@ function fakeStripe(method, url, body) { return { status: 200, body: { object: 'search_result', url: url.pathname, has_more: false, data } }; } if (method === 'GET' && !idOrAction) { - // List, filtered on plain fields, e.g. ?customer=cus_1&status=active - const filters = Object.entries(Object.fromEntries(url.searchParams)).filter( - ([key]) => !['limit', 'starting_after', 'ending_before'].includes(key) && !key.startsWith('expand'), - ); - const data = store.filter((object) => filters.every(([key, value]) => String(object[key]) === value)); - return { status: 200, body: { object: 'list', url: url.pathname, has_more: false, data } }; + return { status: 200, body: { object: 'list', url: url.pathname, has_more: false, data: listStripe(type, store, url) } }; } if (method === 'POST' && !idOrAction) { stripe.counter += 1; diff --git a/packages/join-e2e/tests/settings/legacy/README.md b/packages/join-e2e/tests/settings/legacy/README.md index 1131cbf..29aef92 100644 --- a/packages/join-e2e/tests/settings/legacy/README.md +++ b/packages/join-e2e/tests/settings/legacy/README.md @@ -8,7 +8,11 @@ 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. +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 index d1fbc20..a30dd2d 100644 --- a/packages/join-e2e/tests/settings/legacy/membership-plans.json +++ b/packages/join-e2e/tests/settings/legacy/membership-plans.json @@ -24,7 +24,7 @@ "_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_monthly_gbp": { + "ck_join_flow_membership_plan_legacy-standard": { "_type": "_", "label": "Legacy Standard", "id": "", diff --git a/packages/join-e2e/tests/settings/membership-plans.spec.ts b/packages/join-e2e/tests/settings/membership-plans.spec.ts index 0511e2e..0f57aa3 100644 --- a/packages/join-e2e/tests/settings/membership-plans.spec.ts +++ b/packages/join-e2e/tests/settings/membership-plans.spec.ts @@ -1,8 +1,10 @@ import { Page } from '@playwright/test'; +import { createHash } from 'crypto'; import { CONTINUE } from '../helpers'; import { GLOBAL_PLANS_PAGE, JoinSettingsPage, + MEMBER_EMAIL, expect, fakeApiRequests, getSettings, @@ -31,13 +33,21 @@ import { */ const MAILCHIMP = 'us1.api.mailchimp.com'; -const MEMBER_EMAIL = 'member@example.org'; /** Mailchimp addresses a member by the MD5 of their lower-cased email. */ -const MEMBER_TAGS_PATH = '/3.0/lists/e2e-audience/members/adf3c55fec42518eefba5901a7ca837f/tags'; +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 (ck_join_flow_membership_plan_), or false. */ -const storedPlan = (id: string) => - wpEvalJson | false>(`return get_option(${JSON.stringify(`ck_join_flow_membership_plan_${id}`)});`); +/** 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; @@ -89,8 +99,9 @@ test.describe('Global membership plans', () => { add_tags: 'Supporter, Yearly', }); // With no Price Point ID, the ID is made from the name, frequency and currency. - expect(storedPlan('supporter_yearly_eur')).toMatchObject({ label: 'Supporter', amount: '12', currency: 'EUR' }); - expect(storedPlan('baseline-plan_monthly_gbp')).toMatchObject({ label: 'Baseline Plan', amount: '7' }); + 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' }), @@ -122,8 +133,9 @@ test.describe('Membership plan fields', () => { await settings.addPlan({ name: 'Gold', id: 'gold-tier', price: '20' }); await settings.save(); - expect(storedPlan('gold-tier')).toMatchObject({ label: 'Gold', id: 'gold-tier' }); - expect(storedPlan('gold_monthly_gbp')).toBe(false); + 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. @@ -195,7 +207,9 @@ test.describe('Changing the plan list', () => { 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. + // 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); }); @@ -238,8 +252,25 @@ test.describe('Changing the plan list', () => { } 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', () => { @@ -274,8 +305,9 @@ test.describe('Global plans in Stripe', () => { ]); // Each stored plan remembers its Stripe product and price. - for (const id of ['baseline-plan_weekly_gbp', 'dollars_monthly_usd', 'euros_yearly_eur']) { - expect(storedPlan(id)).toMatchObject({ + 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_/), }); @@ -388,6 +420,20 @@ test.describe('Member tags', () => { 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(); @@ -437,8 +483,9 @@ test.describe('Member tags', () => { await settings.save(); await seedFakeStripe('customers', CUSTOMER); - const oldPrice = (storedPlan('baseline-plan_monthly_gbp') as Record).stripe_price_id; - const newPrice = (storedPlan('gold_monthly_gbp') as Record).stripe_price_id; + 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', diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index c54a694..2709f93 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -142,9 +142,12 @@ export async function joinFormEnv(page: Page, url = STANDARD_JOIN_PAGE): Promise * 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@example.org', ...data }, + data: { firstName: 'Ada', lastName: 'Lovelace', email: MEMBER_EMAIL, ...data }, }); } @@ -278,16 +281,19 @@ export class JoinSettingsPage { /** 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$/ }) }) - .locator('.cf-complex__group'); + .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.page.locator('.cf-container__fields .cf-complex__inserter-button').click(); + await this.plansField().locator('.cf-complex__inserter-button').click(); await expect(this.plans()).toHaveCount(count + 1); await this.editPlan(count, plan); } @@ -341,15 +347,52 @@ export class JoinSettingsPage { } /** - * Checks the page will not save as it stands: either it won't let the save - * be made (today it disables Save while a required field is empty), or it - * rejects it with an error. + * 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 { - if (await this.page.locator('#publish').isDisabled()) { + 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; } - await this.saveExpectingErrors(); + 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 {