From 5452073272cdc8c62197ea791505e49a1ab00815 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:06:20 +0000 Subject: [PATCH 1/3] Add settings page regression tests: foundation and Features tab Tests that pin down what every setting on WP Admin > Join does today, so the settings page can be rebuilt without Carbon Fields and checked against them. Each test changes a setting through the settings page, checks it was saved (read back through Settings::get, so the check survives a storage change) and that it has its effect on the join form and on what the plugin sends out. Foundation (tests/settings): - JoinSettingsPage: the only code that knows how the settings page is built; finds fields by their visible labels. - Each test starts from the seeded site plus one global plan (the settings page refuses to save without one); seed.sh snapshots the options table and tests restore it afterwards. - Legacy fixtures: settings as Carbon Fields stores them, loaded straight into the database, to check existing sites keep working after the rebuild. - A fake-API container standing in for Stripe, Mailchimp, Action Network, Zetkin and the other services: their hostnames resolve to it inside the Docker network and it answers over HTTPS with a test certificate only the E2E containers trust, so the plugin's real SDKs run unchanged. It records requests for tests to check. - WP-CLI now runs in a long-lived container (exec, not run), cutting each call from ~1.5s to ~0.5s. Features tab: every setting, the integration switches' credential checks, their effects on the payment step, and Use Stripe setting global plans up in Stripe on save. A rejected Stripe key crashes the save (Linear JOIN-161): test.fixme. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 23 +- packages/join-e2e/docker-compose.yml | 46 ++- packages/join-e2e/fake-api/certs/ca.crt | 20 ++ packages/join-e2e/fake-api/certs/generate.sh | 17 + packages/join-e2e/fake-api/certs/san.cnf | 22 ++ packages/join-e2e/fake-api/certs/server.crt | 26 ++ packages/join-e2e/fake-api/certs/server.key | 28 ++ packages/join-e2e/fake-api/php-ca.ini | 3 + packages/join-e2e/fake-api/server.js | 168 +++++++++ packages/join-e2e/scripts/env.sh | 4 +- .../join-e2e/scripts/load-raw-options.php | 25 ++ packages/join-e2e/scripts/seed.sh | 4 + packages/join-e2e/tests/settings/fake-api.ts | 53 +++ .../settings/features-integrations.spec.ts | 273 ++++++++++++++ .../join-e2e/tests/settings/features.spec.ts | 338 ++++++++++++++++++ packages/join-e2e/tests/settings/fixtures.ts | 87 +++++ .../join-e2e/tests/settings/legacy/README.md | 15 + .../tests/settings/legacy/features.json | 37 ++ packages/join-e2e/tests/settings/support.ts | 222 ++++++++++++ readme.md | 25 +- 20 files changed, 1423 insertions(+), 13 deletions(-) create mode 100644 packages/join-e2e/fake-api/certs/ca.crt create mode 100644 packages/join-e2e/fake-api/certs/generate.sh create mode 100644 packages/join-e2e/fake-api/certs/san.cnf create mode 100644 packages/join-e2e/fake-api/certs/server.crt create mode 100644 packages/join-e2e/fake-api/certs/server.key create mode 100644 packages/join-e2e/fake-api/php-ca.ini create mode 100644 packages/join-e2e/fake-api/server.js create mode 100644 packages/join-e2e/scripts/load-raw-options.php create mode 100644 packages/join-e2e/tests/settings/fake-api.ts create mode 100644 packages/join-e2e/tests/settings/features-integrations.spec.ts create mode 100644 packages/join-e2e/tests/settings/features.spec.ts create mode 100644 packages/join-e2e/tests/settings/fixtures.ts create mode 100644 packages/join-e2e/tests/settings/legacy/README.md create mode 100644 packages/join-e2e/tests/settings/legacy/features.json create mode 100644 packages/join-e2e/tests/settings/support.ts diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index b4e35f7..698b81e 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -69,6 +69,20 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending - **Validation** for required fields and email format is exercised. +## Settings page coverage + +`tests/settings/` tests the settings on WP Admin > Join: each is saved (read back through `Settings::get`), has its effect, and still works when loaded as Carbon Fields stored it. The readme ("Settings page regression tests") explains how these tests are built. Known problems are `test.fixme` tests linked to Linear issues. + +| 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 | +| Theme | | Not yet | +| Copy | | Not yet | +| Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | +| Logging | | Not yet | +| Debug | | Not yet | + ## What is not covered ### Payment UI rendering @@ -89,16 +103,11 @@ No tests. When `IS_UPDATE_FLOW=true`, personal details are skipped and the form ### Payment method selection page -The `payment-method` step (choosing between Direct Debit and Card when multiple providers are enabled) is never exercised. Tests either mock everything or bypass payment. +Now partly covered by the settings tests: `tests/settings/features-integrations.spec.ts` checks the step appears, with both methods, when GoCardless and Chargebee are both on. Choosing a method and going on from there is still untested. ### Optional data collection flags -The following flags control whether additional fields appear on the details page, but none are tested: - -- `COLLECT_DATE_OF_BIRTH` -- `COLLECT_COUNTY` -- `COLLECT_HEAR_ABOUT_US` -- `COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT` +Now covered by the settings tests (`tests/settings/features.spec.ts`). ### Custom fields diff --git a/packages/join-e2e/docker-compose.yml b/packages/join-e2e/docker-compose.yml index cd7547b..7218126 100644 --- a/packages/join-e2e/docker-compose.yml +++ b/packages/join-e2e/docker-compose.yml @@ -9,6 +9,12 @@ x-wordpress-volumes: &wordpress-volumes - wordpress:/var/www/html - ../join-block:/var/www/html/wp-content/plugins/join-block - ./scripts:/var/www/html/wp-content/e2e-scripts + # Trust the fake API's test CA: PHP's curl and streams, WordPress's own + # HTTP API and the Stripe SDK each have their own CA setting. + - ./fake-api/certs/ca.crt:/usr/local/share/e2e-fake-api-ca.crt:ro + - ./fake-api/php-ca.ini:/usr/local/etc/php/conf.d/zz-e2e-fake-api-ca.ini:ro + - ./fake-api/certs/ca.crt:/var/www/html/wp-includes/certificates/ca-bundle.crt:ro + - ./fake-api/certs/ca.crt:/var/www/html/wp-content/plugins/join-block/vendor/stripe/stripe-php/data/ca-certificates.crt:ro x-wordpress-environment: &wordpress-environment WORDPRESS_DB_HOST: db @@ -17,6 +23,38 @@ x-wordpress-environment: &wordpress-environment WORDPRESS_DB_NAME: wordpress services: + # Stands in for every third-party API the plugin calls. Inside this network + # the aliases below resolve here instead of to the real services. Tests talk + # to its control API on localhost:8890 (tests/settings/fake-api.ts). + fake-api: + image: node:22.23.4-alpine + command: ['node', '/app/server.js'] + volumes: + - ./fake-api:/app:ro + ports: + - '8890:8080' + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:8080/health'] + interval: 2s + timeout: 5s + retries: 30 + networks: + default: + # Keep in step with fake-api/certs/san.cnf. + aliases: + - api.stripe.com + - actionnetwork.org + - api.zetk.in + - api.dev.zetkin.org + - us1.api.mailchimp.com + - e2e.eu.auth0.com + - api.gocardless.com + - api-sandbox.gocardless.com + - e2e.chargebee.com + - api.postcodes.io + - api.getaddress.io + - api.ideal-postcodes.co.uk + db: image: mariadb:11.4 environment: @@ -35,6 +73,8 @@ services: depends_on: db: condition: service_healthy + fake-api: + condition: service_healthy ports: - '8889:80' volumes: *wordpress-volumes @@ -45,10 +85,12 @@ services: define('WP_DEBUG_LOG', true); define('SCRIPT_DEBUG', true); - # One-off WP-CLI runs: docker compose run --rm cli wp + # WP-CLI, kept running so commands start quickly: + # docker compose exec cli wp (or scripts/env.sh wp ) cli: image: wordpress:cli-2.12.0-php8.2 - profiles: [cli] + command: ['sleep', 'infinity'] + init: true user: '33:33' depends_on: - wordpress diff --git a/packages/join-e2e/fake-api/certs/ca.crt b/packages/join-e2e/fake-api/certs/ca.crt new file mode 100644 index 0000000..e19edcf --- /dev/null +++ b/packages/join-e2e/fake-api/certs/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPzCCAiegAwIBAgIUL1NIuEmpA0RWqABA149MgtVN6S8wDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz +ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMC4xLDAqBgNVBAMM +I0pvaW4gRTJFIHRlc3QgQ0EgKG5vdCBmb3IgcmVhbCB1c2UpMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5MZLGxuo1wv3q/KZlHPYs1msn5HP9/MS93px +sfubWlrPxW0CWIxTMtv98kyeNggsQ6pqbFdpLSpDrMI2L4+OTYwTwV+LrzV4neSm +GXHVAqB93QLaHLQwdWdjiGJ8vaX069c8LzNvnZb8KVf7MtLWX2MsRNYJtXCCtxuT +wV3RZDQs4TNOqGikoyRg/JNCxkgBPHNSm7ptCj3rj2rbQGDM7nu1skBzH3L6xKiA +QGiYb+vNDWcdK4I38cA8BbQdS+DXvHIf+Y6Qlq8h6esI7a8pnbhm60RIHNvsEdFq +qPFPowydKT8RyjABZHnajyYXyv3PGeGjXC/hRRTsaJg54GUxiwIDAQABo1MwUTAd +BgNVHQ4EFgQU6xlodRtSMB1hTbE+cfkyMBWT2QUwHwYDVR0jBBgwFoAU6xlodRtS +MB1hTbE+cfkyMBWT2QUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAGjcNh/YqYwRw5w+ilPybR9DHl0OG7/k7VXrGRqml0QvbDD1h4jR4Hqz2AwXr +HFt6LdnXOvlVk+u6t6FvNflFu059sNeJx7b5FRBoTvrjNZfFehoKD5NkuamGGWdk +XIEeG0NIBaYF4DYu9qK1jKL1DcpM4lFOfy55pcNEqyBEyULmpJVYhBAsddb4gtuU +oZHM/rZdp/k463E53SHUPMD3arh3CDcq+JLSr3uDs7nRex7HfY0U7DFd4DlcuuH/ +oWnAGLOcb17zzahyAdsmCetiGuzAp7SU7p6DBLnT3Yr5i/CjhWKoKCDqAIaybIgw +dIdD1NBq1DZNpTYgPl/dvaa5fg== +-----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/generate.sh b/packages/join-e2e/fake-api/certs/generate.sh new file mode 100644 index 0000000..013f30e --- /dev/null +++ b/packages/join-e2e/fake-api/certs/generate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Regenerates the fake API's test CA and server certificate. +# +# Only needed when adding a hostname: add it to san.cnf, run this, and add the +# same hostname to the fake-api network aliases in ../../docker-compose.yml. +# The CA key is deleted afterwards, so these files can't sign anything else. +# Test-only: trusted only inside the E2E Docker network. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 36500 \ + -subj "/CN=Join E2E test CA (not for real use)" +openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \ + -subj "/CN=fake-api" -config san.cnf +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out server.crt -days 36500 -extfile san.cnf -extensions ext +rm -f server.csr ca.srl ca.key diff --git a/packages/join-e2e/fake-api/certs/san.cnf b/packages/join-e2e/fake-api/certs/san.cnf new file mode 100644 index 0000000..f410223 --- /dev/null +++ b/packages/join-e2e/fake-api/certs/san.cnf @@ -0,0 +1,22 @@ +[req] +distinguished_name = dn +[dn] +[ext] +subjectAltName = @alt +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +[alt] +DNS.1 = api.stripe.com +DNS.2 = actionnetwork.org +DNS.3 = api.zetk.in +DNS.4 = api.dev.zetkin.org +DNS.5 = us1.api.mailchimp.com +DNS.6 = e2e.eu.auth0.com +DNS.7 = api.gocardless.com +DNS.8 = api-sandbox.gocardless.com +DNS.9 = e2e.chargebee.com +DNS.10 = api.postcodes.io +DNS.11 = api.getaddress.io +DNS.12 = api.ideal-postcodes.co.uk +DNS.13 = fake-api diff --git a/packages/join-e2e/fake-api/certs/server.crt b/packages/join-e2e/fake-api/certs/server.crt new file mode 100644 index 0000000..667cb04 --- /dev/null +++ b/packages/join-e2e/fake-api/certs/server.crt @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIETTCCAzWgAwIBAgIUcBvmd6AYMCx4hAsRuJYvExc18BUwDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz +ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMBMxETAPBgNVBAMM +CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAreVk2RGX +irbBcE/J6YDanGGDwf404Lh9ni6Akw2zEANeVLxqvKiyi5l8wK1q+ah3J2jqo/LU +KrD65ZuhscZW5V+t9w/KqTkOE/4O76s3wJB8Y4sQcUuW4XtV/Wd028gZORu/DMp9 +A1lK7ysqtuu/6K1D1/8evI6LsMNjrQshh3FXcJiUhcrfgrmM8lWVQE652KJIeRTi +iKQm9Xiwo8+Pj632bbmTfvCA6yLHrhOaLS9OB14XSjzzXSoxqLTLP9UTA6GvMLGi +FIlzl/ch8ZlnI9t/37JiEhBY2QzaEymKl2yLNcgG2YCQx71T8xAuuwu7Yy2E/A4k +C2o/LQcp2iJPowIDAQABo4IBejCCAXYwggEFBgNVHREEgf0wgfqCDmFwaS5zdHJp +cGUuY29tghFhY3Rpb25uZXR3b3JrLm9yZ4ILYXBpLnpldGsuaW6CEmFwaS5kZXYu +emV0a2luLm9yZ4IVdXMxLmFwaS5tYWlsY2hpbXAuY29tghBlMmUuZXUuYXV0aDAu +Y29tghJhcGkuZ29jYXJkbGVzcy5jb22CGmFwaS1zYW5kYm94LmdvY2FyZGxlc3Mu +Y29tghFlMmUuY2hhcmdlYmVlLmNvbYIQYXBpLnBvc3Rjb2Rlcy5pb4IRYXBpLmdl +dGFkZHJlc3MuaW+CGWFwaS5pZGVhbC1wb3N0Y29kZXMuY28udWuCCGZha2UtYXBp +MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0G +A1UdDgQWBBRmOT+xWfG1qKOcrTEPx4PG5jjkwzAfBgNVHSMEGDAWgBTrGWh1G1Iw +HWFNsT5x+TIwFZPZBTANBgkqhkiG9w0BAQsFAAOCAQEAM0haZ0adhRNvYMHkNm3B +IH614SRIsOlFt2SUYi33DYIy0Im0Mk3BGozgx/zY2/8LUu6mon47aKjJJpP922ew +MzzswMoAM7FnKiN0DV9asfsmCH8OeePjFHde4VWU6elTQ2Tb5Lmg0Tqafaew4sB6 +xFe4usEl3AIaYtoZ6MULdsdNOXF42M4xlNp2l7uMcJT1IYQODxByJ1vd40k0q1Uz +uZx4qiRsF7HqV4q2/emjUu/67R871J5uM8aKxvRS2YLjuP2JruTOnjSQbJf1js60 +PcGGWPREVvveXSLJiwD9DpXHjApKTZjMj7lk5fcGYe+Ka0CDxmhHhewiGiblRjcJ +YA== +-----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/server.key b/packages/join-e2e/fake-api/certs/server.key new file mode 100644 index 0000000..2dffa2c --- /dev/null +++ b/packages/join-e2e/fake-api/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt5WTZEZeKtsFw +T8npgNqcYYPB/jTguH2eLoCTDbMQA15UvGq8qLKLmXzArWr5qHcnaOqj8tQqsPrl +m6GxxlblX633D8qpOQ4T/g7vqzfAkHxjixBxS5bhe1X9Z3TbyBk5G78Myn0DWUrv +Kyq267/orUPX/x68jouww2OtCyGHcVdwmJSFyt+CuYzyVZVATrnYokh5FOKIpCb1 +eLCjz4+PrfZtuZN+8IDrIseuE5otL04HXhdKPPNdKjGotMs/1RMDoa8wsaIUiXOX +9yHxmWcj23/fsmISEFjZDNoTKYqXbIs1yAbZgJDHvVPzEC67C7tjLYT8DiQLaj8t +BynaIk+jAgMBAAECggEASIz4vsGiroph+SgozuVmD7F1haW3zPvb2PeAyBmCmA83 +t3WmuJBPcYO3czHG/GMnV0dkAcPJdghBnTMJyoA799Wr4HRzspdtwrSP6blO+OWv +J964nPQS/n71GNSlgh6hO1zSoBUJBDEoXNFvMEL6XaqQMBkt6zAzoqtaNjN7KSTV +qfPyySMcnRJNHZGsSz8i4dekGZDUSwUV7u7kjmp2vlL5vo6T6tlY2JqHYRcm/qdl +GqI6JuH2fFmaLDlP2eyUu3Judqoq0PrwMgcEVz0Rtg7szEUcHrZg6O7YVVWVCyuS ++nC9yPtmbs1oyiXXQsKdItZiy5LwdzuGKXXrXw7WHQKBgQDe7ioZzHhQS39hg87o +YJQJdtlyTemgFywjtItWeX2Arq2n1nj4BioxJlvtEMXuFntYqUgJSOOB2m0n2k2v +duT7EUrON6+xcoHaGDaoT9qZHAzEr5OQb+dJmTUlWWDuU5xGGeBwFmIboO/2vNeS +E9/tqo9L2VU5P9WV2YPHH+pxxQKBgQDHsSQeVPSFzfLw6UvwiMrco8SdU15TOjFF +7hUzNESq/qo3WPuNiAiNDE8frWqVXzMRpbeYpujAFZd+XUONjI29z8CBTbcrZYjp +PKW1VxLn5gL1MCsGModdRnupzp8DVPvRuQhG1wgfqKLtP0wMvhLPkOpO24V5LokL +FRwnaP/aRwKBgQCIdHm6ruiaJYxD8T1OCgezP1dcWwp83pI7DMEZXmYOxV0wVdxX +3NYonZU5J8IvuP0pSPz6Ce6QRlX4Rey1UW5Zq70BOx7E3BVVnOqf3bktbPj3z4zg +pU6KQ0g/o1TsrKsEUcSKd3j39mvLuM2u8ZFo1bS+tbovv74J6kl6MjP8DQKBgH1B +q7oq58YWTNC4RZ327+g+AVH9vSjPU6DdmgaoPnpTi0l0UzWPom12zxq7mCNht5LW +M7dY2p3B7Wih74GWLEJnUgTJTp+17I8AL6DzoTQ8npZVRGd6wlXLpD37ENryL5J5 +Uau9rabODIRsM7O6deokqBQWtj4w5l1BPIur3PB5AoGAYSXUMSGROOwFsJBWMyV0 +Zkki7WTEqtkWD2WA4sggmxQw9jtbuiud558v6BT8RwbolUWICeNlfae0sxNNhMOV +9vQTeemYjZIDTaK8iJ7RdEqoLl27SjhmPsnXdn4+eunBEBUahs2U3QtAJ0SxLSlU +W8tmx1Coy7OXOWnP/jc1PoA= +-----END PRIVATE KEY----- diff --git a/packages/join-e2e/fake-api/php-ca.ini b/packages/join-e2e/fake-api/php-ca.ini new file mode 100644 index 0000000..3655851 --- /dev/null +++ b/packages/join-e2e/fake-api/php-ca.ini @@ -0,0 +1,3 @@ +; E2E only: trust the fake API's test CA (see fake-api/server.js). +curl.cainfo = /usr/local/share/e2e-fake-api-ca.crt +openssl.cafile = /usr/local/share/e2e-fake-api-ca.crt diff --git a/packages/join-e2e/fake-api/server.js b/packages/join-e2e/fake-api/server.js new file mode 100644 index 0000000..766a3b1 --- /dev/null +++ b/packages/join-e2e/fake-api/server.js @@ -0,0 +1,168 @@ +// Fake third-party APIs for the E2E suite. +// +// Inside the E2E Docker network the hostnames of the services the plugin talks +// to (api.stripe.com, actionnetwork.org, ...) resolve to this container, which +// answers over HTTPS with a test certificate the WordPress containers trust +// (see docker-compose.yml). The plugin's real code and SDKs run unchanged. +// +// Every request is recorded. Tests read and clear them, and can override +// responses, through the control API on port 8080 (localhost:8890 from the +// host); see tests/settings/fake-api.ts. +// +// No dependencies: Node's standard library only. + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); +const { URL } = require('url'); + +let requests = []; +let overrides = []; +let stripe = freshStripe(); + +function freshStripe() { + return { counter: 0, objects: { products: [], prices: [], customers: [], subscriptions: [] } }; +} + +function parseBody(raw, contentType) { + if (!raw) return null; + if ((contentType || '').includes('application/json')) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + if ((contentType || '').includes('application/x-www-form-urlencoded')) { + return parseFormEncoded(raw); + } + return raw; +} + +/** Stripe-style form encoding, with nested keys like metadata[membership_plan]. */ +function parseFormEncoded(raw) { + const result = {}; + for (const [key, value] of new URLSearchParams(raw)) { + const parts = key.replace(/\]/g, '').split('['); + let target = result; + parts.forEach((part, index) => { + if (index === parts.length - 1) { + target[part] = value; + } else { + target[part] = target[part] || {}; + target = target[part]; + } + }); + } + return result; +} + +/** A tiny in-memory Stripe: enough for the plugin's product and price set-up. */ +function fakeStripe(method, url, body) { + const match = url.pathname.match(/^\/v1\/([a-z_]+)(?:\/([^/]+))?$/); + if (!match) return null; + const [, type, idOrAction] = match; + const store = stripe.objects[type]; + if (!store) return null; + const prefix = { products: 'prod', prices: 'price', customers: 'cus', subscriptions: 'sub' }[type]; + + if (method === 'GET' && idOrAction === 'search') { + const query = url.searchParams.get('query') || ''; + // e.g. active:'true' AND metadata['membership_plan']:'standard_monthly_gbp' + const conditions = [...query.matchAll(/(metadata\['([^']+)'\]|[a-z_]+):'([^']*)'/g)].map( + ([, field, metadataKey, value]) => ({ field, metadataKey, value }), + ); + const data = store.filter((object) => + conditions.every(({ field, metadataKey, value }) => { + if (metadataKey) return (object.metadata || {})[metadataKey] === value; + if (field === 'active') return String(object.active) === value; + if (field === 'type') return (object.recurring ? 'recurring' : 'one_time') === value; + return String(object[field]) === value; + }), + ); + return { status: 200, body: { object: 'search_result', 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 }; + if (body && body.unit_amount) object.unit_amount = Number(body.unit_amount); + store.push(object); + return { status: 200, body: object }; + } + if (method === 'GET' && idOrAction) { + const object = store.find((candidate) => candidate.id === idOrAction); + return object ? { status: 200, body: object } : { status: 404, body: { error: { type: 'invalid_request_error', message: 'No such object' } } }; + } + return null; +} + +function respond(host, method, url, body) { + const override = overrides.find( + (rule) => + (!rule.host || rule.host === host) && + (!rule.method || rule.method === method) && + (!rule.path || new RegExp(rule.path).test(url.pathname)), + ); + if (override) return { status: override.status || 200, body: override.body ?? {} }; + if (host === 'api.stripe.com') { + const result = fakeStripe(method, url, body); + if (result) return result; + } + return { status: 200, body: {} }; +} + +function handleService(request, response) { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const host = (request.headers.host || '').split(':')[0]; + const url = new URL(request.url, `https://${host}`); + const raw = Buffer.concat(chunks).toString('utf8'); + const body = parseBody(raw, request.headers['content-type']); + requests.push({ + time: new Date().toISOString(), + host, + method: request.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: request.headers, + body, + }); + const result = respond(host, request.method, url, body); + response.writeHead(result.status, { 'Content-Type': 'application/json' }); + response.end(typeof result.body === 'string' ? result.body : JSON.stringify(result.body)); + }); +} + +function handleControl(request, response) { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const send = (status, body) => { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); + }; + if (request.method === 'GET' && request.url === '/requests') return send(200, requests); + if (request.method === 'POST' && request.url === '/reset') { + requests = []; + overrides = []; + stripe = freshStripe(); + return send(200, { ok: true }); + } + if (request.method === 'POST' && request.url === '/overrides') { + overrides.unshift(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + return send(200, { ok: true }); + } + if (request.method === 'GET' && request.url === '/health') return send(200, { ok: true }); + send(404, { error: 'unknown control endpoint' }); + }); +} + +const certs = path.join(__dirname, 'certs'); +https + .createServer({ key: fs.readFileSync(path.join(certs, 'server.key')), cert: fs.readFileSync(path.join(certs, 'server.crt')) }, handleService) + .listen(443); +http.createServer(handleService).listen(80); +http.createServer(handleControl).listen(8080); +console.log('Fake APIs listening on 443 and 80; control API on 8080'); diff --git a/packages/join-e2e/scripts/env.sh b/packages/join-e2e/scripts/env.sh index 2c8745d..63692ae 100755 --- a/packages/join-e2e/scripts/env.sh +++ b/packages/join-e2e/scripts/env.sh @@ -13,10 +13,8 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." URL="http://localhost:8889" READY_TIMEOUT="${WP_READY_TIMEOUT:-120}" -# COMPOSE_PROGRESS rather than --progress: older Compose versions reject the -# flag but ignore the variable. wp() { - COMPOSE_PROGRESS=quiet docker compose run --rm --no-deps -T cli wp "$@" + docker compose exec -T cli wp "$@" } # On first start the wordpress container copies WordPress into the shared diff --git a/packages/join-e2e/scripts/load-raw-options.php b/packages/join-e2e/scripts/load-raw-options.php new file mode 100644 index 0000000..ed0821d --- /dev/null +++ b/packages/join-e2e/scripts/load-raw-options.php @@ -0,0 +1,25 @@ + $value) { + update_option($name, $value); +} + +WP_CLI::success(count($options) . ' options written.'); diff --git a/packages/join-e2e/scripts/seed.sh b/packages/join-e2e/scripts/seed.sh index 48afd63..c49aa82 100755 --- a/packages/join-e2e/scripts/seed.sh +++ b/packages/join-e2e/scripts/seed.sh @@ -16,4 +16,8 @@ cd "${PACKAGE_DIR}" scripts/env.sh wp eval-file /var/www/html/wp-content/e2e-scripts/setup.php scripts/env.sh wp rewrite flush --hard +# The settings tests restore this after changing settings (tests/settings/support.ts). +echo "--- Snapshotting options ---" +scripts/env.sh wp db export /var/www/html/wp-content/e2e-options-snapshot.sql --tables="$(scripts/env.sh wp db prefix)options" + echo "--- Seed complete ---" diff --git a/packages/join-e2e/tests/settings/fake-api.ts b/packages/join-e2e/tests/settings/fake-api.ts new file mode 100644 index 0000000..8cdabc8 --- /dev/null +++ b/packages/join-e2e/tests/settings/fake-api.ts @@ -0,0 +1,53 @@ +/** + * The fake third-party APIs (fake-api/server.js): what the plugin sent them, + * and how they should answer. + */ + +const CONTROL_URL = process.env.FAKE_API_URL || 'http://localhost:8890'; + +export type FakeApiRequest = { + time: string; + host: string; + method: string; + path: string; + query: Record; + headers: Record; + body: any; +}; + +async function control(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${CONTROL_URL}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`Fake API control ${method} ${path} failed: ${response.status}`); + } + return response.json(); +} + +/** Forgets recorded requests, response overrides and fake Stripe objects. */ +export async function resetFakeApi(): Promise { + await control('POST', '/reset'); +} + +/** Requests the plugin has made, optionally only those to one host. */ +export async function fakeApiRequests(host?: string): Promise { + const requests: FakeApiRequest[] = await control('GET', '/requests'); + return host ? requests.filter((request) => request.host === host) : requests; +} + +/** + * Makes the fake answer matching requests with the given status and body, + * until the next reset. `path` is a regular expression. + */ +export async function overrideFakeApi(rule: { + host?: string; + method?: string; + path?: string; + status?: number; + body?: unknown; +}): Promise { + await control('POST', '/overrides', rule); +} diff --git a/packages/join-e2e/tests/settings/features-integrations.spec.ts b/packages/join-e2e/tests/settings/features-integrations.spec.ts new file mode 100644 index 0000000..31e2123 --- /dev/null +++ b/packages/join-e2e/tests/settings/features-integrations.spec.ts @@ -0,0 +1,273 @@ +import { Page } from '@playwright/test'; +import { CONTINUE } from '../helpers'; +import { + STANDARD_JOIN_PAGE, + expect, + expectDetailsAccepted, + expectDetailsRejected, + fakeApiRequests, + getSetting, + joinFormEnv, + openJoinForm, + overrideFakeApi, + stubChargebee, + test, + wpEvalJson, +} from './fixtures'; + +/** + * Settings page, Features tab: the integration switches. + * + * Here: each switch is refused without its credentials, is saved with them, + * and changes the join form as expected. What the plugin then sends to + * Mailchimp, Action Network and Zetkin on a join is covered with the + * Integrations tab. + */ + +const SWITCHES: { label: string; key: string; name: string }[] = [ + { label: 'Create Auth0 Account', key: 'create_auth0_account', name: 'Auth0' }, + { label: 'Use Zetkin', key: 'use_zetkin', name: 'Zetkin' }, + { label: 'Use GoCardless', key: 'use_gocardless', name: 'GoCardless' }, + { label: 'Use Stripe', key: 'use_stripe', name: 'Stripe' }, + { label: 'Use Chargebee', key: 'use_chargebee', name: 'ChargeBee' }, + { label: 'Use Action Network', key: 'use_action_network', name: 'Action Network' }, + { label: 'Use Mailchimp', key: 'use_mailchimp', name: 'MailChimp' }, +]; + +test.describe('Integration switches', () => { + for (const { label, key, name } of SWITCHES) { + test(`${label} is refused without its credentials, and nothing is saved`, async ({ settings }) => { + await settings.open('Features'); + await settings.setCheckbox(label, true); + // Proves the whole save is refused, not just the switch. + await settings.setCheckbox('Collect County', true); + + expect(await settings.saveExpectingErrors()).toEqual([`${name} credentials are missing (see Integrations tab).`]); + expect(getSetting(key)).toBe(false); + expect(getSetting('collect_county')).toBe(false); + }); + + test(`${label} is saved with its credentials`, async ({ settings }) => { + await settings.open('Features'); + await settings.enableIntegration(label); + await settings.save(); + + expect(getSetting(key)).toBe(true); + }); + } + + test('Use GoCardless Custom Pages is refused without Use GoCardless', async ({ settings }) => { + await settings.open('Features'); + await settings.setCheckbox('Use GoCardless Custom Pages', true); + + expect(await settings.saveExpectingErrors()).toEqual([ + 'Must select Use GoCardless to use GoCardless Custom Pages.', + ]); + expect(getSetting('use_gocardless_api')).toBe(false); + }); + + test('Use Mailchimp reaches the join form settings', async ({ settings, page }) => { + expect((await joinFormEnv(page)).USE_MAILCHIMP).toBe(false); + await settings.open('Features'); + await settings.enableIntegration('Use Mailchimp'); + await settings.save(); + + expect((await joinFormEnv(page)).USE_MAILCHIMP).toBe(true); + }); +}); + +test.describe('Use Stripe', () => { + test('saving with Stripe on sets up each global plan in Stripe, and remembers it', async ({ settings }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + + const stripe = await fakeApiRequests('api.stripe.com'); + const productCreated = stripe.find((request) => request.method === 'POST' && request.path === '/v1/products'); + const priceCreated = stripe.find((request) => request.method === 'POST' && request.path === '/v1/prices'); + expect(productCreated?.body).toMatchObject({ + name: 'Membership: Baseline Plan', + metadata: { membership_plan: 'baseline-plan_monthly_gbp' }, + }); + expect(priceCreated?.body).toMatchObject({ + product: 'prod_e2e_1', + unit_amount: '700', + currency: 'GBP', + recurring: { interval: 'month' }, + }); + expect(productCreated?.headers.authorization).toBe('Bearer sk_test_e2e'); + + const saved = wpEvalJson>( + `return get_option('ck_join_flow_membership_plan_baseline-plan_monthly_gbp');`, + ); + expect(saved).toMatchObject({ stripe_product_id: 'prod_e2e_1', stripe_price_id: 'price_e2e_2' }); + }); + + test('saving again reuses the Stripe product and price rather than creating duplicates', async ({ settings }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + await settings.save(); + + const created = (await fakeApiRequests('api.stripe.com')).filter((request) => request.method === 'POST'); + expect(created.map((request) => request.path)).toEqual(['/v1/products', '/v1/prices']); + }); + + // Linear JOIN-161: a rejected Stripe key (or Stripe being unreachable) makes + // the settings page save end in a PHP fatal error instead of a message. + test.fixme('a Stripe key Stripe rejects gives an error message, not a crash', async ({ settings }) => { + await overrideFakeApi({ + host: 'api.stripe.com', + status: 401, + body: { error: { type: 'invalid_request_error', message: 'Invalid API Key provided: sk_test_e2e' } }, + }); + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + + const errors = await settings.saveExpectingErrors(); + expect(errors.join(' ')).toContain('Stripe'); + }); +}); + +test.describe('Create Auth0 Account', () => { + test('is off by default: no password is asked for', async ({ page }) => { + await openJoinForm(page); + await expect(page.locator('input#password')).toHaveCount(0); + }); + + test('when on, the form asks for a strong password', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Create Auth0 Account'); + await settings.save(); + + expect((await joinFormEnv(page)).CREATE_AUTH0_ACCOUNT).toBe(true); + + await openJoinForm(page); + const password = page.locator('input#password'); + await expect(password).toBeVisible(); + + await password.fill('weakpassword'); + await expectDetailsRejected(page); + await expect(page.locator('input#password.is-invalid')).toBeVisible(); + + await password.fill('Str0ng!password'); + await expectDetailsAccepted(page); + }); +}); + +test.describe('Payment providers', () => { + /** From the details step, through the plan step, to whatever comes next. */ + async function continueToPayment(page: Page) { + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); + await page.locator('label.radio-panel').first().click(); + await page.locator(CONTINUE).click(); + } + + const logo = (page: Page, provider: string) => page.locator(`.progress-steps img[alt="${provider}"]`); + + test('none is on by default: no provider logos', async ({ page }) => { + const env = await joinFormEnv(page); + expect([env.USE_STRIPE, env.USE_GOCARDLESS, env.USE_CHARGEBEE]).toEqual([false, false, false]); + await openJoinForm(page); + for (const provider of ['Stripe', 'GoCardless', 'Chargebee']) { + await expect(logo(page, provider)).toHaveCount(0); + } + }); + + test('Use Stripe shows the Stripe logo and passes the publishable key to the form', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + + const env = await joinFormEnv(page); + expect(env.USE_STRIPE).toBe(true); + expect(env.STRIPE_PUBLISHABLE_KEY).toBe('pk_test_e2e'); + + await openJoinForm(page); + await expect(logo(page, 'Stripe')).toBeVisible(); + }); + + test('Use GoCardless sends Direct Debit members to GoCardless\'s hosted pages', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.save(); + + expect((await joinFormEnv(page)).USE_GOCARDLESS).toBe(true); + + await openJoinForm(page); + await expect(logo(page, 'GoCardless')).toBeVisible(); + + const redirectTo = `${STANDARD_JOIN_PAGE}?gocardless-hosted-page`; + let redirectRequest: Record | null = null; + await page.route('**/wp-json/join/v1/gocardless/auth', async (route) => { + redirectRequest = route.request().postDataJSON(); + await route.fulfill({ json: { href: redirectTo, gcBillingRequestId: 'BRQ-E2E' } }); + }); + + await continueToPayment(page); + await page.waitForURL(`**${redirectTo}`); + expect(redirectRequest).toMatchObject({ paymentMethod: 'directDebit', membership: 'standard' }); + }); + + test('Use GoCardless Custom Pages takes bank details in the form instead', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.setCheckbox('Use GoCardless Custom Pages', true); + await settings.save(); + + expect(getSetting('use_gocardless_api')).toBe(true); + expect((await joinFormEnv(page)).USE_GOCARDLESS_API).toBe(true); + + await openJoinForm(page); + let redirected = false; + await page.route('**/wp-json/join/v1/gocardless/auth', async (route) => { + redirected = true; + await route.abort(); + }); + + await continueToPayment(page); + await expect(page.locator('.progress-step--current')).toContainText('Payment'); + await expect(page.locator('input#ddAccountNumber')).toBeVisible(); + expect(redirected).toBe(false); + }); + + test('Use Chargebee shows the Chargebee logo and sets Chargebee up with the site and publishable key', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Chargebee'); + await settings.save(); + + const env = await joinFormEnv(page); + expect(env.USE_CHARGEBEE).toBe(true); + expect(env.CHARGEBEE_SITE_NAME).toBe('e2e'); + expect(env.CHARGEBEE_API_PUBLISHABLE_KEY).toBe('e2e-chargebee-publishable-key'); + + const html = await (await page.request.get(STANDARD_JOIN_PAGE)).text(); + expect(html).toContain('https://js.chargebee.com/v2/chargebee.js'); + + await stubChargebee(page); + await openJoinForm(page); + await expect(logo(page, 'Chargebee')).toBeVisible(); + expect(await page.evaluate(() => (window as any).chargebeeInitOptions)).toEqual({ + site: 'e2e', + publishableKey: 'e2e-chargebee-publishable-key', + }); + }); + + test('with two providers offering different methods, members choose how to pay', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.enableIntegration('Use Chargebee'); + await settings.save(); + + await stubChargebee(page); + await openJoinForm(page); + await continueToPayment(page); + + await expect(page.getByText('How would you like to pay?')).toBeVisible(); + await expect(page.locator('[id="paymentMethod-directDebit"]')).toBeAttached(); + await expect(page.locator('[id="paymentMethod-creditCard"]')).toBeAttached(); + await expect(page.locator('[role="radiogroup"]')).toContainText('Direct Debit'); + await expect(page.locator('[role="radiogroup"]')).toContainText('Credit or Debit Card'); + }); +}); diff --git a/packages/join-e2e/tests/settings/features.spec.ts b/packages/join-e2e/tests/settings/features.spec.ts new file mode 100644 index 0000000..e82a102 --- /dev/null +++ b/packages/join-e2e/tests/settings/features.spec.ts @@ -0,0 +1,338 @@ +import { CONTINUE } from '../helpers'; +import { + FREE_JOIN_PAGE, + expect, + expectDetailsAccepted, + expectDetailsRejected, + getSetting, + getSettings, + joinFormEnv, + loadLegacySettings, + openJoinForm, + stubChargebee, + test, +} from './fixtures'; + +/** + * Settings page, Features tab. + * + * For each setting: change it on the settings page, check the plugin reads + * back the new value, that it reaches the join form's settings (env), and that + * the form behaves accordingly. The integration switches are covered in + * features-integrations.spec.ts. + */ + +test.describe('Collect Date Of Birth', () => { + test('is off by default: no date of birth fields', async ({ page }) => { + expect(getSetting('collect_date_of_birth')).toBe(false); + await openJoinForm(page); + await expect(page.locator('input#dobDay')).toHaveCount(0); + }); + + test('when on, the form asks for a date of birth and requires it', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Date Of Birth', true); + await settings.save(); + + expect(getSetting('collect_date_of_birth')).toBe(true); + expect((await joinFormEnv(page)).COLLECT_DATE_OF_BIRTH).toBe(true); + + await openJoinForm(page); + await expect(page.locator('input#dobDay')).toBeVisible(); + await expect(page.locator('input#dobMonth')).toBeVisible(); + await expect(page.locator('input#dobYear')).toBeVisible(); + + await page.locator('input#dobYear').fill(''); + await expectDetailsRejected(page); + await expect(page.locator('input#dobYear.is-invalid')).toBeVisible(); + }); +}); + +test.describe('Require age 18 or over, and Minimum Age', () => { + /** A year of birth that makes someone exactly `age` years old today, if born on 1 January. */ + const bornYearsAgo = (age: number) => String(new Date().getFullYear() - age); + + async function enterBirthYear(page: import('@playwright/test').Page, year: string) { + await page.locator('input#dobDay').fill('1'); + await page.locator('input#dobMonth').fill('1'); + await page.locator('input#dobYear').fill(year); + } + + test('without it, people of any age can sign up', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Date Of Birth', true); + await settings.save(); + + expect(getSetting('require_age_18_or_over')).toBe(false); + await openJoinForm(page); + await enterBirthYear(page, bornYearsAgo(12)); + await expectDetailsAccepted(page); + }); + + test('with it and no minimum age set, under-18s are turned away', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Date Of Birth', true); + await settings.setCheckbox('Require age 18 or over', true); + await settings.setText('Minimum Age', ''); + await settings.save(); + + expect(getSetting('require_age_18_or_over')).toBe(true); + const env = await joinFormEnv(page); + expect(env.REQUIRE_AGE_18_OR_OVER).toBe(true); + expect(env.MINIMUM_AGE).toBe(18); + + await openJoinForm(page); + await enterBirthYear(page, bornYearsAgo(17)); + await expectDetailsRejected(page); + await expect(page.getByText('You must be at least 18 years old to sign up')).toBeVisible(); + + await enterBirthYear(page, bornYearsAgo(18)); + await expectDetailsAccepted(page); + }); + + test('the minimum age can be raised', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Date Of Birth', true); + await settings.setCheckbox('Require age 18 or over', true); + await settings.setText('Minimum Age', '21'); + await settings.save(); + + expect(getSetting('minimum_age')).toBe('21'); + expect((await joinFormEnv(page)).MINIMUM_AGE).toBe(21); + + await openJoinForm(page); + await enterBirthYear(page, bornYearsAgo(20)); + await expectDetailsRejected(page); + await expect(page.getByText('You must be at least 21 years old to sign up')).toBeVisible(); + + await enterBirthYear(page, bornYearsAgo(21)); + await expectDetailsAccepted(page); + }); +}); + +test.describe("Ask 'How did you hear about us?'", () => { + test('is off by default', async ({ page }) => { + expect(getSetting('collect_hear_about_us')).toBe(false); + await openJoinForm(page); + await expect(page.locator('select#howDidYouHearAboutUs')).toHaveCount(0); + }); + + test('when on, the form asks, with a details box for "Other"', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox("Ask 'How did you hear about us?'", true); + await settings.save(); + + expect(getSetting('collect_hear_about_us')).toBe(true); + expect((await joinFormEnv(page)).COLLECT_HEAR_ABOUT_US).toBe(true); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'How did you hear about us?' })).toBeVisible(); + const question = page.locator('select#howDidYouHearAboutUs'); + await expect(question).toBeVisible(); + await expect(page.locator('#howDidYouHearAboutUsDetails')).toHaveCount(0); + await question.selectOption('other'); + await expect(page.locator('#howDidYouHearAboutUsDetails')).toBeVisible(); + }); +}); + +test.describe('Collect County', () => { + test('is off by default', async ({ page }) => { + expect(getSetting('collect_county')).toBe(false); + await openJoinForm(page); + await expect(page.locator('input#addressCounty')).toHaveCount(0); + }); + + test('when on, the form asks for a county and requires it', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect County', true); + await settings.save(); + + expect(getSetting('collect_county')).toBe(true); + expect((await joinFormEnv(page)).COLLECT_COUNTY).toBe(true); + + await openJoinForm(page); + const county = page.locator('input#addressCounty'); + await expect(county).toBeVisible(); + await county.fill(''); + await expectDetailsRejected(page); + await expect(page.locator('input#addressCounty.is-invalid')).toBeVisible(); + }); +}); + +test.describe('Contact consent', () => { + const emailConsent = (page: import('@playwright/test').Page) => page.locator('input#contactByEmail'); + const phoneConsent = (page: import('@playwright/test').Page) => page.locator('input#contactByPhone'); + + test('is not asked for by default', async ({ page }) => { + expect(getSetting('collect_phone_and_email_contact_consent')).toBe(false); + await openJoinForm(page); + await expect(emailConsent(page)).toHaveCount(0); + await expect(phoneConsent(page)).toHaveCount(0); + }); + + test('Collect Phone And Email Contact Consent shows unticked, optional checkboxes', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Phone And Email Contact Consent', true); + await settings.save(); + + expect(getSetting('collect_phone_and_email_contact_consent')).toBe(true); + expect((await joinFormEnv(page)).COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT).toBe(true); + + await openJoinForm(page); + await expect(emailConsent(page)).not.toBeChecked(); + await expect(phoneConsent(page)).not.toBeChecked(); + await expectDetailsAccepted(page); + }); + + test('Consent checkboxes checked by default pre-ticks them', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Phone And Email Contact Consent', true); + await settings.setCheckbox('Consent checkboxes checked by default', true); + await settings.save(); + + expect(getSetting('consent_checked_by_default')).toBe(true); + expect((await joinFormEnv(page)).CONSENT_CHECKED_BY_DEFAULT).toBe(true); + + await openJoinForm(page); + await expect(emailConsent(page)).toBeChecked(); + await expect(phoneConsent(page)).toBeChecked(); + }); + + test('Email consent required blocks the form until email consent is given', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Phone And Email Contact Consent', true); + await settings.setCheckbox('Email consent required', true); + await settings.save(); + + expect(getSetting('require_email_consent')).toBe(true); + expect((await joinFormEnv(page)).REQUIRE_EMAIL_CONSENT).toBe(true); + + await openJoinForm(page); + await expectDetailsRejected(page); + await expect(page.locator('input#contactByEmail.is-invalid')).toBeVisible(); + await emailConsent(page).check(); + await expectDetailsAccepted(page); + }); + + test('Phone consent required blocks the form until phone consent is given', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Phone And Email Contact Consent', true); + await settings.setCheckbox('Phone consent required', true); + await settings.save(); + + expect(getSetting('require_phone_consent')).toBe(true); + expect((await joinFormEnv(page)).REQUIRE_PHONE_CONSENT).toBe(true); + + await openJoinForm(page); + await expectDetailsRejected(page); + await expect(page.locator('input#contactByPhone.is-invalid')).toBeVisible(); + await phoneConsent(page).check(); + await expectDetailsAccepted(page); + }); +}); + +test.describe('Enable international phone numbers', () => { + const US_NUMBER = '2025550123'; + + test('is off by default: only UK numbers are accepted', async ({ page }) => { + expect(getSetting('enable_international_phone_numbers')).toBe(false); + await openJoinForm(page); + await expect(page.locator('select#phoneCountry')).toHaveCount(0); + await page.locator('input#phoneNumber').fill(US_NUMBER); + await expectDetailsRejected(page); + await expect(page.getByText('Please enter a valid UK phone number')).toBeVisible(); + }); + + test('when on, a country code can be chosen and other countries\' numbers are accepted', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Enable international phone numbers', true); + await settings.save(); + + expect(getSetting('enable_international_phone_numbers')).toBe(true); + expect((await joinFormEnv(page)).ENABLE_INTERNATIONAL_PHONE_NUMBERS).toBe(true); + + await openJoinForm(page); + const country = page.locator('select#phoneCountry'); + await expect(country).toBeVisible(); + await page.locator('input#phoneNumber').fill(US_NUMBER); + await expectDetailsRejected(page); + await expect(page.getByText('Please enter a valid phone number')).toBeVisible(); + + await country.selectOption('US'); + await expectDetailsAccepted(page); + }); +}); + +test.describe('Hide price display for zero-price items', () => { + async function goToPlans(page: import('@playwright/test').Page) { + await openJoinForm(page, FREE_JOIN_PAGE); + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); + } + + test('is off by default: a free plan shows its £0 price', async ({ page }) => { + expect(getSetting('hide_zero_price_display')).toBe(false); + await goToPlans(page); + await expect(page.locator('[role="radiogroup"]')).toContainText('£0'); + }); + + test('when on, a free plan shows no price', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Hide price display for zero-price items', true); + await settings.save(); + + expect(getSetting('hide_zero_price_display')).toBe(true); + expect((await joinFormEnv(page, FREE_JOIN_PAGE)).HIDE_ZERO_PRICE_DISPLAY).toBe(true); + + await goToPlans(page); + await expect(page.locator('[role="radiogroup"]')).not.toContainText('£0'); + }); +}); + +test.describe('Features saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. + test('are still read, reach the join form, and change it', async ({ page }) => { + loadLegacySettings('features'); + + const switches = [ + 'collect_date_of_birth', + 'require_age_18_or_over', + 'collect_hear_about_us', + 'collect_county', + 'collect_phone_and_email_contact_consent', + 'consent_checked_by_default', + 'require_email_consent', + 'require_phone_consent', + 'enable_international_phone_numbers', + 'hide_zero_price_display', + 'create_auth0_account', + 'use_zetkin', + 'use_gocardless', + 'use_gocardless_api', + 'use_chargebee', + 'use_stripe', + 'use_action_network', + 'use_mailchimp', + ]; + const saved = getSettings([...switches, 'minimum_age']); + for (const key of switches) { + expect(saved[key], key).toBe(true); + } + expect(saved.minimum_age).toBe('21'); + + const env = await joinFormEnv(page); + for (const key of switches.filter((key) => !['use_zetkin', 'use_action_network'].includes(key))) { + expect(env[key.toUpperCase()], key).toBe(true); + } + expect(env.MINIMUM_AGE).toBe(21); + + await stubChargebee(page); + await openJoinForm(page); + await expect(page.locator('input#dobYear')).toBeVisible(); + await expect(page.locator('input#addressCounty')).toBeVisible(); + await expect(page.locator('select#howDidYouHearAboutUs')).toBeVisible(); + await expect(page.locator('select#phoneCountry')).toBeVisible(); + await expect(page.locator('input#contactByEmail')).toBeChecked(); + await expect(page.locator('input#password')).toBeVisible(); + }); +}); diff --git a/packages/join-e2e/tests/settings/fixtures.ts b/packages/join-e2e/tests/settings/fixtures.ts new file mode 100644 index 0000000..ebed372 --- /dev/null +++ b/packages/join-e2e/tests/settings/fixtures.ts @@ -0,0 +1,87 @@ +import { Page, test as base, expect } from '@playwright/test'; +import { mkdtempSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { CONTINUE, loginAsAdmin, mockRestEndpoints } from '../helpers'; +import { resetFakeApi } from './fake-api'; +import { JoinSettingsPage, STANDARD_JOIN_PAGE, resetSettings, resetToSettingsBaseline } from './support'; + +export * from './support'; +export * from './fake-api'; + +/** + * Fixtures for the settings regression tests. + * + * - `settings`: the WP Admin > Join settings page, in a browser logged in as + * the admin (logged in once per worker). + * - `page`: a separate, logged-out browser for the join form, as a visitor + * sees it. + * + * Every test starts from resetToSettingsBaseline() with the fake APIs reset, + * and afterwards settings are put back as the seed left them, so other specs + * are unaffected. + */ +export const test = base.extend<{ settings: JoinSettingsPage; baseline: void }, { adminStorageState: string }>({ + baseline: [ + async ({}, use) => { + await resetFakeApi(); + resetToSettingsBaseline(); + await use(); + resetSettings(); + }, + { auto: true }, + ], + + adminStorageState: [ + async ({ browser }, use) => { + const file = path.join(mkdtempSync(path.join(os.tmpdir(), 'join-e2e-')), 'admin.json'); + const page = await browser.newPage(); + await loginAsAdmin(page); + await page.context().storageState({ path: file }); + await page.close(); + await use(file); + }, + { scope: 'worker' }, + ], + + settings: async ({ browser, adminStorageState }, use) => { + const context = await browser.newContext({ storageState: adminStorageState }); + await use(new JoinSettingsPage(await context.newPage())); + await context.close(); + }, +}); + +export { expect }; + +/** Opens a join form as a visitor, with the form's REST calls stubbed. */ +export async function openJoinForm(page: Page, url = STANDARD_JOIN_PAGE): Promise { + await mockRestEndpoints(page); + await page.goto(url); + await page.waitForSelector('input#firstName'); +} + +/** + * Serves a stand-in for Chargebee's script (with Chargebee on, the form won't + * start without it), which records the settings the form initialises + * Chargebee with in window.chargebeeInitOptions. + */ +export async function stubChargebee(page: Page): Promise { + await page.route('https://js.chargebee.com/**', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: 'window.Chargebee = { init: function (options) { window.chargebeeInitOptions = options; return {}; }, getInstance: function () { return {}; } };', + }), + ); +} + +/** Clicks Continue and checks the form stayed on the details step. */ +export async function expectDetailsRejected(page: Page): Promise { + await page.locator(CONTINUE).click(); + await expect(page.locator('.progress-step--current')).toContainText('Your Details'); +} + +/** Clicks Continue and checks the form moved on from the details step. */ +export async function expectDetailsAccepted(page: Page): Promise { + await page.locator(CONTINUE).click(); + await expect(page.locator('.progress-step--current')).not.toContainText('Your Details'); +} diff --git a/packages/join-e2e/tests/settings/legacy/README.md b/packages/join-e2e/tests/settings/legacy/README.md new file mode 100644 index 0000000..2901bd0 --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/README.md @@ -0,0 +1,15 @@ +# Legacy settings fixtures + +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 `""`. + +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 +still behaves accordingly. They stand for sites that saved their settings +before the settings page was rebuilt. + +Don't regenerate these after the rebuild: they must keep the old format. If +the plugin stops reading this format on purpose (for example after a one-off +migration), change the tests to run that migration first. diff --git a/packages/join-e2e/tests/settings/legacy/features.json b/packages/join-e2e/tests/settings/legacy/features.json new file mode 100644 index 0000000..a01ef09 --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/features.json @@ -0,0 +1,37 @@ +{ + "_collect_date_of_birth": "yes", + "_require_age_18_or_over": "yes", + "_minimum_age": "21", + "_collect_hear_about_us": "yes", + "_collect_county": "yes", + "_collect_phone_and_email_contact_consent": "yes", + "_consent_checked_by_default": "yes", + "_require_email_consent": "yes", + "_require_phone_consent": "yes", + "_enable_international_phone_numbers": "yes", + "_hide_zero_price_display": "yes", + "_create_auth0_account": "yes", + "_use_zetkin": "yes", + "_use_gocardless": "yes", + "_use_gocardless_api": "yes", + "_use_chargebee": "yes", + "_use_stripe": "yes", + "_use_action_network": "yes", + "_use_mailchimp": "yes", + "_auth0_domain": "e2e.eu.auth0.com", + "_auth0_client_id": "e2e-auth0-client-id", + "_auth0_client_secret": "e2e-auth0-client-secret", + "_auth0_management_audience": "https://e2e.eu.auth0.com/api/v2/", + "_zetkin_organisation_id": "1", + "_zetkin_join_form_id": "2", + "_zetkin_join_form_submit_token": "e2e-zetkin-token", + "_gc_access_token": "e2e-gocardless-token", + "_stripe_publishable_key": "pk_test_e2e", + "_stripe_secret_key": "sk_test_e2e", + "_chargebee_site_name": "e2e", + "_chargebee_api_key": "e2e-chargebee-key", + "_chargebee_api_publishable_key": "e2e-chargebee-publishable-key", + "_action_network_api_key": "e2e-action-network-key", + "_mailchimp_api_key": "e2e-mailchimp-key-us1", + "_mailchimp_audience_id": "e2e-audience" +} diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts new file mode 100644 index 0000000..caca10b --- /dev/null +++ b/packages/join-e2e/tests/settings/support.ts @@ -0,0 +1,222 @@ +import { Locator, Page, expect } from '@playwright/test'; +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; +import path from 'path'; + +/** + * Shared helpers for the settings page regression tests (tests/settings). + * + * These tests pin down what every setting on WP Admin > Join does today, so + * that the settings page can be rebuilt without Carbon Fields and checked + * against them. Each test changes a setting through the admin page, then + * checks that the change was saved (read back through the plugin's own + * Settings::get, so the check survives a change of storage format) and that + * it has the effect the rest of the plugin expects. + * + * Everything that knows how the current settings page is built lives in + * JoinSettingsPage. When the page is rebuilt, that class is the part to + * rewrite; the specs should not need to change. + */ + +const PACKAGE_DIR = path.resolve(__dirname, '../..'); + +/** Inside the WordPress container. Written by scripts/seed.sh. */ +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/'; + +/** Runs WP-CLI against the E2E site and returns its standard output. */ +export function wp(...args: string[]): string { + return execFileSync('bash', ['scripts/env.sh', 'wp', ...args], { + cwd: PACKAGE_DIR, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +/** Runs PHP inside WordPress and returns the JSON it echoes, decoded. */ +export function wpEvalJson(php: string): T { + const output = wp('eval', `echo "\\n" . wp_json_encode((function () { ${php} })());`); + // The plugin can print log lines before the value; the value is the last line. + return JSON.parse(output.split('\n').pop() as string) as T; +} + +/** + * A setting's value as the plugin reads it (Settings::get), by its key on the + * settings page, e.g. 'collect_county'. + */ +export function getSetting(key: string): T { + return wpEvalJson(`return \\CommonKnowledge\\JoinBlock\\Settings::get(${JSON.stringify(key)});`); +} + +/** Several settings at once, by key (each WP-CLI call takes a second or two). */ +export function getSettings(keys: string[]): Record { + return wpEvalJson(` + $values = []; + foreach (json_decode(${JSON.stringify(JSON.stringify(keys))}) as $key) { + $values[$key] = \\CommonKnowledge\\JoinBlock\\Settings::get($key); + } + return $values; + `); +} + +/** + * Puts every WordPress option back as scripts/seed.sh left it, undoing any + * settings a test changed. Options only: users, pages and posts are kept. + */ +export function resetSettings(): void { + try { + wp('db', 'import', OPTIONS_SNAPSHOT); + } catch (error) { + throw new Error( + `Could not restore the options snapshot. Run scripts/seed.sh (npm test does this) first.\n${error}`, + ); + } +} + +/** + * The state every settings test starts from: the seeded site, plus the one + * global membership plan the settings page needs before it will save anything + * ("You must add at least one membership plan"). + */ +export function resetToSettingsBaseline(): void { + resetSettings(); + wpEvalJson(` + carbon_set_theme_option('membership_plans', [[ + 'label' => 'Baseline Plan', + 'amount' => '7', + 'frequency' => 'monthly', + 'currency' => 'GBP', + ]]); + return true; + `); +} + +/** + * Writes raw option rows straight into the database, bypassing the settings + * page. Used to load settings as Carbon Fields stored them before the settings + * page was rebuilt (see tests/settings/legacy), to check that existing sites + * keep working. + */ +export function loadRawOptions(options: Record): void { + wp('eval-file', '/var/www/html/wp-content/e2e-scripts/load-raw-options.php', JSON.stringify(options)); +} + +/** Loads tests/settings/legacy/.json: settings as Carbon Fields stored them. */ +export function loadLegacySettings(name: string): void { + loadRawOptions(JSON.parse(readFileSync(path.join(__dirname, 'legacy', `${name}.json`), 'utf8'))); +} + +/** The settings the plugin hands the join form: the page's