diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000..cbe4b26
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,38 @@
+name: Playwright
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ playwright:
+ name: Playwright
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+
+ - run: npm ci
+
+ - run: npx playwright install --with-deps chromium
+
+ - run: npm run env:start
+
+ - run: npm run test:e2e
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report
+ path: tests/E2E/.report
+ include-hidden-files: true
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 6ac53d5..2ddf915 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
# Local clone managed by Makefile (not tracked in this repository)
/site/
+/node_modules/
+/tests/E2E/.results/
+/tests/E2E/.report/
diff --git a/README.md b/README.md
index a343b33..8f701ef 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,24 @@ On a fresh WordPress database, `make up` also performs the initial WordPress
installation via WP-CLI and restarts the WordPress container once so the
`wordpress-docker` entrypoint can install the configured plugins and themes.
+## End-to-end tests
+
+End-to-end tests cover the customer workflows across WordPress and Nextcloud:
+signing up for a plan, logging into Nextcloud with the store account, renewing,
+cancelling and changing plan.
+
+They run on a separate stack built from scratch, with the plugins and theme
+declared in `docker-compose.override.yml`, so they never touch the local
+database. They need Docker and Node.js:
+
+```bash
+npm ci
+npx playwright install chromium
+npm run env:start # WordPress on :8891, Nextcloud on :8892, Mailpit on :8893
+npm run test:e2e
+npm run env:stop
+```
+
## Integration Flow
WordPress is the customer-facing commerce and account portal (plans, subscriptions, invoices, payments, and account reports).
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..951c80c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,59 @@
+{
+ "name": "libresign-saas",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "libresign-saas",
+ "license": "AGPL-3.0-or-later",
+ "devDependencies": {
+ "@playwright/test": "^1.63.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..621a564
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "libresign-saas",
+ "private": true,
+ "license": "AGPL-3.0-or-later",
+ "description": "End-to-end tests for the LibreSign SaaS stack",
+ "scripts": {
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "env:start": "sh tests/E2E/support/setup.sh",
+ "env:stop": "docker compose -f tests/E2E/docker-compose.yml down -v"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.63.0"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..25378b4
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig( {
+ testDir: 'tests/E2E',
+ outputDir: 'tests/E2E/.results',
+ workers: 1,
+ fullyParallel: false,
+ forbidOnly: Boolean( process.env.CI ),
+ retries: process.env.CI ? 1 : 0,
+ timeout: 120_000,
+ reporter: process.env.CI
+ ? [
+ [ 'github' ],
+ [
+ 'html',
+ { open: 'never', outputFolder: 'tests/E2E/.report' },
+ ],
+ ]
+ : [ [ 'list' ] ],
+ use: {
+ ...devices[ 'Desktop Chrome' ],
+ baseURL: 'http://localhost:8891',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ launchOptions: {
+ args: [ '--host-resolver-rules=MAP nextcloud:80 127.0.0.1:8892' ],
+ },
+ },
+} );
diff --git a/tests/E2E/NextcloudLogin.spec.ts b/tests/E2E/NextcloudLogin.spec.ts
new file mode 100644
index 0000000..23f3bf3
--- /dev/null
+++ b/tests/E2E/NextcloudLogin.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from '@playwright/test';
+
+import { nextcloud } from './support/nextcloud';
+import { aSubscriber, logInAsAdmin, logInToTheStore, newCustomer } from './support/store';
+import { wp } from './support/wordpress';
+
+test.describe( 'Logging into Nextcloud with the store account', () => {
+ test( 'a store account without a plan is refused', async ( { browser } ) => {
+ const customer = newCustomer( 'Elisa' );
+ wp( 'user', 'create', customer.email, customer.email, `--user_pass=${ customer.password }`, '--role=customer' );
+
+ await nextcloud.expectWrongLoginOrPassword( browser, customer.email, customer.password );
+ expect( await nextcloud.account( customer.email ) ).toBeNull();
+ } );
+
+ test( 'a subscriber with a wrong password is refused', async ( { browser, page } ) => {
+ const { customer } = await aSubscriber( page, 'Fernanda' );
+
+ await nextcloud.expectWrongLoginOrPassword( browser, customer.email, 'not-the-password' );
+ } );
+
+ test.fail( 'a password changed in the account details replaces the old one', { annotation: { type: 'issue', description: 'https://github.com/LibreSign/wordpress_login_backend/issues/22' } }, async ( { browser, page } ) => {
+ const { customer } = await aSubscriber( page, 'Gustavo' );
+ const newPassword = `${ customer.password }-changed`;
+
+ await logInToTheStore( page, customer.email, customer.password );
+ await page.goto( '/account/edit-account/' );
+ await page.locator( '#password_current' ).fill( customer.password );
+ await page.locator( '#password_1' ).fill( newPassword );
+ await page.locator( '#password_2' ).fill( newPassword );
+ await page.getByRole( 'button', { name: 'Save changes' } ).click();
+ await expect( page.getByText( 'Account details changed successfully.' ) ).toBeVisible();
+
+ await nextcloud.expectToLogIn( browser, customer.email, newPassword );
+ await nextcloud.expectWrongLoginOrPassword( browser, customer.email, customer.password );
+ } );
+
+ test( 'an e-mail changed in the account details is copied to Nextcloud', async ( { browser, page } ) => {
+ const { customer } = await aSubscriber( page, 'Heitor' );
+ const newEmail = newCustomer( 'Heitor' ).email;
+
+ await logInToTheStore( page, customer.email, customer.password );
+ await page.goto( '/account/edit-account/' );
+ await page.locator( '#account_email' ).fill( newEmail );
+ await page.getByRole( 'button', { name: 'Save changes' } ).click();
+ await expect( page.getByText( 'Account details changed successfully.' ) ).toBeVisible();
+
+ expect( await nextcloud.account( customer.email ) ).toMatchObject( { email: newEmail } );
+ } );
+
+ test( 'a store account deleted by the admin is refused', async ( { browser, page } ) => {
+ const { customer } = await aSubscriber( page, 'Iara' );
+ await nextcloud.expectToLogIn( browser, customer.email, customer.password );
+
+ await logInAsAdmin( page );
+ await page.goto( `/wp-admin/users.php?s=${ encodeURIComponent( customer.email ) }` );
+ const userRow = page.locator( '#the-list tr' ).filter( { hasText: customer.email } );
+ await userRow.hover();
+ page.once( 'dialog', ( dialog ) => dialog.accept() );
+ await userRow.getByRole( 'link', { name: 'Delete', exact: true } ).click();
+ await page.getByRole( 'radio', { name: 'Delete all content.' } ).check();
+ await page.getByRole( 'button', { name: 'Confirm Deletion' } ).click();
+ await expect( page.getByText( 'User deleted.' ) ).toBeVisible();
+
+ await nextcloud.expectAccountDisabled( browser, customer.email, customer.password );
+ } );
+} );
diff --git a/tests/E2E/PlanChange.spec.ts b/tests/E2E/PlanChange.spec.ts
new file mode 100644
index 0000000..943ced9
--- /dev/null
+++ b/tests/E2E/PlanChange.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from '@playwright/test';
+
+import { nextcloud } from './support/nextcloud';
+import { aSubscriber, logInToTheStore, placeTheOrder, plansInTheStore, STORE_URL } from './support/store';
+import { subscriptionStatus } from './support/wordpress';
+
+const GIGABYTE = 1024 ** 3;
+
+test.describe( 'Changing plan', () => {
+ test( 'a visitor sees every plan, a Basic subscriber only Basic and its upgrade', async ( { page } ) => {
+ expect( ( await plansInTheStore( page ) ).sort() ).toEqual( [ 'Basic', 'Enterprise', 'Professional' ] );
+
+ const { customer, subscriptionId } = await aSubscriber( page, 'Marta' );
+ await logInToTheStore( page, customer.email, customer.password );
+
+ expect( ( await plansInTheStore( page ) ).sort() ).toEqual( [ 'Basic', 'Enterprise' ] );
+
+ await page.goto( `${ STORE_URL }/account/view-subscription/${ subscriptionId }/` );
+ await page.getByRole( 'link', { name: 'Upgrade' } ).click();
+ await expect( page.getByText( 'Choose a new subscription.' ) ).toBeVisible();
+ await expect( page.getByRole( 'link', { name: 'Enterprise', exact: true } ) ).toBeVisible();
+ await expect( page.getByRole( 'link', { name: 'Basic', exact: true } ) ).toBeVisible();
+ await expect( page.getByRole( 'link', { name: 'Professional', exact: true } ) ).toHaveCount( 0 );
+ } );
+
+ test.fail( 'upgrading from Basic to Enterprise raises the workspace storage to 800 GB', { annotation: { type: 'issue', description: 'https://github.com/LibreSign/woocommerce-nextcloud-admin-group-manager/issues/33' } }, async ( { browser, page } ) => {
+ const { customer, subscriptionId } = await aSubscriber( page, 'Nilo' );
+ await logInToTheStore( page, customer.email, customer.password );
+
+ await page.goto( `${ STORE_URL }/account/view-subscription/${ subscriptionId }/` );
+ await page.getByRole( 'link', { name: 'Upgrade' } ).click();
+ await page.getByRole( 'link', { name: 'Enterprise', exact: true } ).click();
+ await page.locator( '.cfvsw-swatches-option', { hasText: 'Monthly' } ).click();
+ await page.getByRole( 'button', { name: 'Switch subscription' } ).click();
+ await page.goto( `${ STORE_URL }/checkout/` );
+ await page.getByRole( 'button', { name: 'Switch subscription' } ).click();
+ await expect( page ).toHaveURL( /\/checkout\/order-received\/\d+\//, { timeout: 90_000 } );
+
+ expect( subscriptionStatus( subscriptionId ) ).toBe( 'active' );
+ expect( await nextcloud.account( customer.email ) ).toMatchObject( { enabled: true, quota: { quota: 800 * GIGABYTE } } );
+ await nextcloud.expectToLogIn( browser, customer.email, customer.password );
+ } );
+} );
diff --git a/tests/E2E/SignUp.spec.ts b/tests/E2E/SignUp.spec.ts
new file mode 100644
index 0000000..37a1d17
--- /dev/null
+++ b/tests/E2E/SignUp.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test';
+
+import { mailTo } from './support/mailpit';
+import { nextcloud } from './support/nextcloud';
+import { logInToTheStore, newCustomer, setThePasswordFromTheWelcomeMail, signUp, subscriptionOf } from './support/store';
+import { orderStatus, runTheScheduledNextcloudSyncRetry, subscriptionStatus } from './support/wordpress';
+
+const GIGABYTE = 1024 ** 3;
+
+const plans = [
+ { plan: 'Basic', term: 'Monthly', storage: 2 },
+ { plan: 'Professional', term: 'Yearly', storage: 120 },
+] as const;
+
+test.describe( 'Signing up for a plan', () => {
+ for ( const { plan, term, storage } of plans ) {
+ test( `a visitor who signs up for ${ plan } ${ term.toLowerCase() } gets a Nextcloud workspace with ${ storage } GB`, async ( { page } ) => {
+ const customer = newCustomer( 'Ana' );
+
+ await signUp( page, customer, plan, term );
+
+ await expect( page.getByRole( 'heading', { name: 'Order completed' } ) ).toBeVisible();
+ expect( subscriptionStatus( await subscriptionOf( page ) ) ).toBe( 'active' );
+
+ const workspace = await nextcloud.account( customer.email );
+ expect( workspace ).toMatchObject( {
+ enabled: true,
+ email: customer.email,
+ displayname: `${ customer.firstName } ${ customer.lastName }`,
+ groups: [ customer.email ],
+ subadmin: [ customer.email ],
+ quota: { quota: storage * GIGABYTE },
+ } );
+ } );
+ }
+
+ test( 'the customer sets a password from the welcome mail and reaches Nextcloud from the account page', async ( { page } ) => {
+ const customer = newCustomer( 'Bruno' );
+ await signUp( page, customer, 'Basic', 'Monthly' );
+
+ await setThePasswordFromTheWelcomeMail( page, customer );
+ await logInToTheStore( page, customer.email, customer.password );
+ const nextcloudTab = page.waitForEvent( 'popup' );
+ await page.getByRole( 'link', { name: 'Ir para o sistema de assinaturas' } ).click();
+ const nextcloudPage = await nextcloudTab;
+ await nextcloudPage.getByLabel( 'Account name or email' ).fill( customer.email );
+ await nextcloudPage.getByLabel( 'Password', { exact: true } ).fill( customer.password );
+ await nextcloudPage.getByRole( 'button', { name: 'Log in', exact: true } ).click();
+
+ await expect( nextcloudPage ).toHaveURL( /\/apps\/dashboard\// );
+ } );
+
+ test( 'the store and the customer are notified by mail', async ( { page } ) => {
+ const customer = newCustomer( 'Clara' );
+
+ const orderId = await signUp( page, customer, 'Basic', 'Monthly' );
+
+ await mailTo( customer.email, 'order has been received' );
+ await mailTo( 'admin@example.com', `#${ orderId }` );
+ } );
+
+ test.fail( 'a workspace ordered while Nextcloud is down is created by the scheduled retry', { annotation: { type: 'issue', description: 'https://github.com/LibreSign/woocommerce-nextcloud-admin-group-manager/issues/34' } }, async ( { page } ) => {
+ const customer = newCustomer( 'Diego' );
+
+ nextcloud.stop();
+ let orderId: number;
+ try {
+ orderId = await signUp( page, customer, 'Basic', 'Monthly' );
+ } finally {
+ nextcloud.start();
+ }
+
+ expect( await nextcloud.account( customer.email ) ).toBeNull();
+ expect( orderStatus( orderId ) ).toBe( 'processing' );
+
+ runTheScheduledNextcloudSyncRetry( orderId );
+
+ expect( await nextcloud.account( customer.email ) ).toMatchObject( { enabled: true, quota: { quota: 2 * GIGABYTE } } );
+ expect( orderStatus( orderId ) ).toBe( 'completed' );
+ } );
+} );
diff --git a/tests/E2E/SubscriptionLifecycle.spec.ts b/tests/E2E/SubscriptionLifecycle.spec.ts
new file mode 100644
index 0000000..b289ecf
--- /dev/null
+++ b/tests/E2E/SubscriptionLifecycle.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test';
+
+import { nextcloud } from './support/nextcloud';
+import { aSubscriber, chooseThePlan, logInToTheStore, placeTheOrder, STORE_URL } from './support/store';
+import { runScheduledSubscriptionAction, subscriptionStatus } from './support/wordpress';
+
+test.describe( 'Keeping the plan over time', () => {
+ test( 'an unpaid renewal suspends the workspace until the customer pays it', async ( { browser, page } ) => {
+ const { customer, subscriptionId } = await aSubscriber( page, 'Joana' );
+
+ runScheduledSubscriptionAction( 'woocommerce_scheduled_subscription_payment', subscriptionId );
+
+ expect( subscriptionStatus( subscriptionId ) ).toBe( 'on-hold' );
+ await nextcloud.expectAccountDisabled( browser, customer.email, customer.password );
+
+ await logInToTheStore( page, customer.email, customer.password );
+ await page.goto( `/account/view-subscription/${ subscriptionId }/` );
+ await page.getByRole( 'link', { name: 'Pay', exact: true } ).click();
+ await page.getByRole( 'button', { name: 'Renew subscription' } ).click();
+ await expect( page ).toHaveURL( /\/checkout\/order-received\/\d+\//, { timeout: 90_000 } );
+
+ expect( subscriptionStatus( subscriptionId ) ).toBe( 'active' );
+ await nextcloud.expectToLogIn( browser, customer.email, customer.password );
+ } );
+
+ test( 'a customer who cancels loses the workspace and gets it back by signing up again', async ( { browser, page } ) => {
+ const { customer, subscriptionId } = await aSubscriber( page, 'Karina' );
+
+ await logInToTheStore( page, customer.email, customer.password );
+ await page.goto( `/account/view-subscription/${ subscriptionId }/` );
+ await page.getByRole( 'button', { name: 'Cancel', exact: true } ).click();
+ await page.getByRole( 'link', { name: 'Yes, cancel subscription' } ).click();
+ await expect( page.getByText( 'Your subscription has been cancelled.' ) ).toBeVisible();
+
+ expect( subscriptionStatus( subscriptionId ) ).toBe( 'pending-cancel' );
+ await nextcloud.expectAccountDisabled( browser, customer.email, customer.password );
+
+ runScheduledSubscriptionAction( 'woocommerce_scheduled_subscription_end_of_prepaid_term', subscriptionId );
+
+ expect( subscriptionStatus( subscriptionId ) ).toBe( 'cancelled' );
+ await nextcloud.expectAccountDisabled( browser, customer.email, customer.password );
+
+ await chooseThePlan( page, 'Basic', 'Monthly' );
+ await page.goto( `${ STORE_URL }/checkout/` );
+ await placeTheOrder( page );
+
+ await nextcloud.expectToLogIn( browser, customer.email, customer.password );
+ expect( await nextcloud.account( customer.email ) ).toMatchObject( { enabled: true, groups: [ customer.email ] } );
+ } );
+} );
diff --git a/tests/E2E/docker-compose.yml b/tests/E2E/docker-compose.yml
new file mode 100644
index 0000000..f6fff67
--- /dev/null
+++ b/tests/E2E/docker-compose.yml
@@ -0,0 +1,86 @@
+name: saas-e2e
+
+services:
+ mariadb:
+ image: mariadb:12
+ environment:
+ MARIADB_ROOT_PASSWORD: root
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 2s
+ retries: 30
+ volumes:
+ - ./support/mariadb-init.sql:/docker-entrypoint-initdb.d/mariadb-init.sql:ro
+
+ mailpit:
+ image: axllent/mailpit:v1.31.2
+ ports:
+ - "8893:8025"
+
+ wordpress:
+ extends:
+ file: ../../docker-compose.override.yml
+ service: wordpress
+ image: ghcr.io/librecodecoop/wordpress-docker:latest
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ environment:
+ WORDPRESS_DB_HOST: mariadb
+ WORDPRESS_DB_NAME: wordpress
+ WORDPRESS_DB_USER: root
+ WORDPRESS_DB_PASSWORD: root
+ WORDPRESS_TABLE_PREFIX: wp_
+ PROD_SITE_URL: ""
+ LOCAL_SITE_URL: http://localhost:8891
+ NEXTCLOUD_LOCAL_URL: http://nextcloud
+ WORDPRESS_LOCAL_USERS_PASSWORD: ""
+ WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS: "0"
+ HOST_UID: "33"
+ HOST_GID: "33"
+ healthcheck:
+ test: ["CMD", "pidof", "php-fpm"]
+ interval: 2s
+ retries: 300
+ volumes:
+ - wordpress:/var/www/html
+ - ./support:/e2e:ro
+
+ nginx:
+ image: nginx
+ depends_on:
+ - wordpress
+ ports:
+ - "8891:80"
+ volumes:
+ - wordpress:/var/www/html:ro
+ - ../../.docker/wordpress-nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+
+ nextcloud:
+ image: nextcloud:35-apache
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ environment:
+ MYSQL_HOST: mariadb
+ MYSQL_DATABASE: nextcloud
+ MYSQL_USER: nextcloud
+ MYSQL_PASSWORD: nextcloud
+ NEXTCLOUD_ADMIN_USER: admin
+ NEXTCLOUD_ADMIN_PASSWORD: admin
+ NEXTCLOUD_TRUSTED_DOMAINS: localhost nextcloud
+ SMTP_HOST: mailpit
+ SMTP_PORT: "1025"
+ MAIL_FROM_ADDRESS: nextcloud
+ MAIL_DOMAIN: example.com
+ ports:
+ - "8892:80"
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fs http://localhost/status.php | grep -q '\"installed\":true'"]
+ interval: 2s
+ retries: 300
+ volumes:
+ - ./support/nextcloud-setup.sh:/docker-entrypoint-hooks.d/post-installation/nextcloud-setup.sh:ro
+
+volumes:
+ wordpress:
diff --git a/tests/E2E/support/mailpit.ts b/tests/E2E/support/mailpit.ts
new file mode 100644
index 0000000..59a5e35
--- /dev/null
+++ b/tests/E2E/support/mailpit.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test';
+
+const MAILPIT_API = 'http://localhost:8893/api/v1';
+
+type Message = { ID: string; Subject: string };
+
+export async function mailTo( address: string, subject: string ): Promise< string > {
+ let id = '';
+ await expect( async () => {
+ const response = await fetch( `${ MAILPIT_API }/search?query=${ encodeURIComponent( `to:"${ address }" subject:"${ subject }"` ) }` );
+ const { messages } = ( await response.json() ) as { messages: Message[] };
+ expect( messages ).not.toHaveLength( 0 );
+ id = messages[ 0 ].ID;
+ } ).toPass( { timeout: 30_000 } );
+
+ const message = await fetch( `${ MAILPIT_API }/message/${ id }` );
+ return ( ( await message.json() ) as { Text: string } ).Text;
+}
diff --git a/tests/E2E/support/mariadb-init.sql b/tests/E2E/support/mariadb-init.sql
new file mode 100644
index 0000000..7782983
--- /dev/null
+++ b/tests/E2E/support/mariadb-init.sql
@@ -0,0 +1,4 @@
+CREATE DATABASE wordpress;
+CREATE DATABASE nextcloud;
+CREATE USER 'nextcloud'@'%' IDENTIFIED BY 'nextcloud';
+GRANT ALL PRIVILEGES ON nextcloud.* TO 'nextcloud'@'%';
diff --git a/tests/E2E/support/nextcloud-setup.sh b/tests/E2E/support/nextcloud-setup.sh
new file mode 100755
index 0000000..41aecee
--- /dev/null
+++ b/tests/E2E/support/nextcloud-setup.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -e
+
+for app in admin_group_manager wordpress_login_backend; do
+ mkdir -p "custom_apps/$app"
+ curl -fsSL "https://github.com/LibreSign/$app/archive/refs/heads/main.tar.gz" | tar -xz --strip-components=1 -C "custom_apps/$app"
+done
+
+php occ app:install groupquota --force --keep-disabled
+php occ app:enable wordpress_login_backend
+php occ app:enable admin_group_manager --force
+php occ app:enable groupquota --force
+php occ config:system:set wordpress_dsn --value "mysql:host=mariadb;port=3306;dbname=wordpress;user=root;password=root"
+php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean
+php occ config:system:set ratelimit.protection.enabled --value=false --type=boolean
diff --git a/tests/E2E/support/nextcloud.ts b/tests/E2E/support/nextcloud.ts
new file mode 100644
index 0000000..8c51135
--- /dev/null
+++ b/tests/E2E/support/nextcloud.ts
@@ -0,0 +1,66 @@
+import { Browser, expect, Page } from '@playwright/test';
+
+import { compose } from './wordpress';
+
+const NEXTCLOUD_API = 'http://localhost:8892';
+
+const NEXTCLOUD_URL = 'http://nextcloud';
+
+export interface NextcloudAccount {
+ enabled: boolean;
+ email: string;
+ displayname: string;
+ groups: string[];
+ subadmin: string[];
+ quota: { quota: number };
+}
+
+export const nextcloud = {
+ async account( id: string ): Promise< NextcloudAccount | null > {
+ const response = await fetch( `${ NEXTCLOUD_API }/ocs/v2.php/cloud/users/${ encodeURIComponent( id ) }?format=json`, {
+ headers: {
+ Authorization: 'Basic ' + Buffer.from( 'admin:admin' ).toString( 'base64' ),
+ 'OCS-APIRequest': 'true',
+ },
+ } );
+ if ( response.status === 404 ) {
+ return null;
+ }
+ return ( ( await response.json() ) as { ocs: { data: NextcloudAccount } } ).ocs.data;
+ },
+
+ async logIn( browser: Browser, login: string, password: string ): Promise< Page > {
+ const page = await ( await browser.newContext() ).newPage();
+ await page.goto( `${ NEXTCLOUD_URL }/login` );
+ await page.getByLabel( 'Account name or email' ).fill( login );
+ await page.getByLabel( 'Password', { exact: true } ).fill( password );
+ await page.getByRole( 'button', { name: 'Log in', exact: true } ).click();
+ return page;
+ },
+
+ async expectToLogIn( browser: Browser, login: string, password: string ): Promise< void > {
+ const page = await this.logIn( browser, login, password );
+ await expect( page ).toHaveURL( /\/apps\/dashboard\// );
+ await page.context().close();
+ },
+
+ async expectWrongLoginOrPassword( browser: Browser, login: string, password: string ): Promise< void > {
+ const page = await this.logIn( browser, login, password );
+ await expect( page.getByText( 'Wrong login or password.' ) ).toBeVisible();
+ await page.context().close();
+ },
+
+ async expectAccountDisabled( browser: Browser, login: string, password: string ): Promise< void > {
+ const page = await this.logIn( browser, login, password );
+ await expect( page.getByText( 'This account is disabled' ) ).toBeVisible();
+ await page.context().close();
+ },
+
+ stop(): void {
+ compose( 'stop', 'nextcloud' );
+ },
+
+ start(): void {
+ compose( 'up', '-d', '--wait', 'nextcloud' );
+ },
+};
diff --git a/tests/E2E/support/seed.php b/tests/E2E/support/seed.php
new file mode 100644
index 0000000..9098382
--- /dev/null
+++ b/tests/E2E/support/seed.php
@@ -0,0 +1,189 @@
+ 'LibreSign',
+ 'timezone_string' => 'America/Sao_Paulo',
+ 'users_can_register' => 0,
+ 'woocommerce_coming_soon' => 'no',
+ 'woocommerce_currency' => 'USD',
+ 'woocommerce_currency_pos' => 'left_space',
+ 'woocommerce_price_decimal_sep' => ',',
+ 'woocommerce_price_thousand_sep' => '.',
+ 'woocommerce_price_num_decimals' => 2,
+ 'woocommerce_default_country' => 'BR:RJ',
+ 'woocommerce_calc_taxes' => 'no',
+ 'woocommerce_enable_guest_checkout' => 'yes',
+ 'woocommerce_enable_checkout_login_reminder' => 'no',
+ 'woocommerce_enable_signup_and_login_from_checkout' => 'no',
+ 'woocommerce_enable_myaccount_registration' => 'no',
+ 'woocommerce_registration_generate_username' => 'yes',
+ 'woocommerce_registration_generate_password' => 'yes',
+ 'woocommerce_checkout_company_field' => 'hidden',
+ 'woocommerce_checkout_address_2_field' => 'optional',
+ 'woocommerce_checkout_phone_field' => 'optional',
+ 'woocommerce_email_from_name' => 'LibreSign',
+ 'woocommerce_email_from_address' => 'noreply@example.com',
+ 'woocommerce_subscriptions_allow_switching' => 'grouped',
+ 'woocommerce_subscriptions_apportion_recurring_price' => 'no',
+ 'woocommerce_subscriptions_enable_early_renewal' => 'yes',
+ 'woocommerce_subscriptions_enable_retry' => 'no',
+ 'woocommerce_subscriptions_multiple_purchase' => 'yes',
+ 'woocommerce_subscriptions_accept_manual_renewals' => 'yes',
+ 'woocommerce_subscriptions_add_to_cart_button_text' => 'Sign up now',
+ 'woocommerce_subscriptions_order_button_text' => 'Sign up now',
+ 'woocommerce_subscriptions_switch_button_text' => 'Upgrade',
+);
+
+wc_transaction_query( 'start' );
+
+foreach ( $options as $name => $value ) {
+ update_option( $name, $value );
+}
+
+update_option(
+ 'woocommerce_cod_settings',
+ array(
+ 'enabled' => 'yes',
+ 'title' => 'Cash on delivery',
+ 'enable_for_virtual' => 'yes',
+ )
+);
+
+$pages = array(
+ 'woocommerce_shop_page_id' => 'store',
+ 'woocommerce_myaccount_page_id' => 'account',
+);
+foreach ( $pages as $option => $slug ) {
+ wp_update_post(
+ array(
+ 'ID' => (int) get_option( $option ),
+ 'post_name' => $slug,
+ )
+ );
+}
+
+$category = wp_insert_term( 'LibreSign', 'product_cat', array( 'slug' => 'libresign' ) );
+
+$plans = array(
+ 'basic' => array(
+ 'name' => 'Basic',
+ 'monthly' => '55',
+ 'yearly' => '660',
+ 'discount' => '600',
+ 'storage' => '2 Gb',
+ 'quota' => '2Gb',
+ 'features' => array( 'LibreSign' ),
+ 'apps' => array( 'libresign' ),
+ 'featured' => true,
+ ),
+ 'professional' => array(
+ 'name' => 'Professional',
+ 'monthly' => '150',
+ 'yearly' => '7920',
+ 'discount' => '7200',
+ 'storage' => '120 Gb',
+ 'quota' => '120Gb',
+ 'features' => array( 'LibreSign', 'Forms', 'Calendar', 'Task Manager', 'Document editor' ),
+ 'apps' => array( 'libresign', 'forms', 'calendar', 'deck', 'onlyoffice' ),
+ 'featured' => false,
+ ),
+ 'enterprise' => array(
+ 'name' => 'Enterprise',
+ 'monthly' => '1000',
+ 'yearly' => '7920',
+ 'discount' => '7200',
+ 'storage' => '800 Gb',
+ 'quota' => '800Gb',
+ 'features' => array( 'LibreSign', 'Forms', 'Calendar', 'Task Manager', 'Document editor', 'Video conference', 'Suport/Consultancy', 'Custom domain', 'Custom logo', 'Custom colors' ),
+ 'apps' => array( 'libresign', 'forms', 'calendar', 'deck', 'onlyoffice' ),
+ 'featured' => true,
+ ),
+);
+
+$attribute = static function ( string $name, array $options, bool $visible, bool $variation = false, int $position = 0 ): WC_Product_Attribute {
+ $product_attribute = new WC_Product_Attribute();
+ $product_attribute->set_name( $name );
+ $product_attribute->set_options( $options );
+ $product_attribute->set_visible( $visible );
+ $product_attribute->set_variation( $variation );
+ $product_attribute->set_position( $position );
+ return $product_attribute;
+};
+
+$plan_ids = array();
+foreach ( $plans as $slug => $plan ) {
+ $product = new WC_Product_Variable_Subscription();
+ $product->set_name( $plan['name'] );
+ $product->set_slug( $slug );
+ $product->set_status( 'publish' );
+ $product->set_featured( $plan['featured'] );
+ $product->set_sold_individually( true );
+ $product->set_category_ids( array( $category['term_id'] ) );
+ $product->set_default_attributes( array( 'term-length' => 'yearly' ) );
+ $product->set_attributes(
+ array(
+ $attribute( 'Term length', array( 'monthly', 'yearly' ), true, true, 0 ),
+ $attribute( 'Available features', $plan['features'], true, false, 1 ),
+ $attribute( 'Storage', array( $plan['storage'] ), true, false, 2 ),
+ $attribute( 'nextcloud-string-quota', array( $plan['quota'] ), false, false, 3 ),
+ $attribute( 'nextcloud-list-apps', $plan['apps'], false, false, 4 ),
+ )
+ );
+ $product->update_meta_data( '_subscription_limit', 'active' );
+ $product->update_meta_data( 'restrict_herself_upsells_switch', 'yes' );
+ $product->update_meta_data(
+ 'cfvsw_product_attr_term-length',
+ array(
+ 'type' => 'label',
+ 'monthly' => array( 'label' => 'Monthly' ),
+ 'yearly' => array( 'label' => 'Yearly' ),
+ )
+ );
+ $product->save();
+
+ $terms = array(
+ 'monthly' => array( 'month', $plan['monthly'], $plan['monthly'], sprintf( 'Yearly: $ %s,00', number_format( (float) $plan['yearly'], 0, ',', '.' ) ) ),
+ 'yearly' => array( 'year', $plan['yearly'], $plan['discount'], 'Discount: 10%' ),
+ );
+ foreach ( $terms as $term => list( $period, $regular_price, $price, $description ) ) {
+ $variation = new WC_Product_Subscription_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'term-length' => $term ) );
+ $variation->set_virtual( true );
+ $variation->set_regular_price( $regular_price );
+ $variation->set_sale_price( $price === $regular_price ? '' : $price );
+ $variation->set_description( $description );
+ $variation->update_meta_data( '_subscription_price', $regular_price );
+ $variation->update_meta_data( '_subscription_period', $period );
+ $variation->update_meta_data( '_subscription_period_interval', 1 );
+ $variation->update_meta_data( '_subscription_length', 0 );
+ $variation->update_meta_data( '_subscription_sign_up_fee', 0 );
+ $variation->update_meta_data( '_subscription_trial_length', 0 );
+ $variation->update_meta_data( '_subscription_trial_period', 'month' );
+ $variation->save();
+ }
+
+ WC_Product_Variable::sync( $product->get_id() );
+ $plan_ids[ $slug ] = $product->get_id();
+}
+
+foreach ( array( 'basic', 'professional' ) as $slug ) {
+ $product = wc_get_product( $plan_ids[ $slug ] );
+ $product->set_upsell_ids( array( $plan_ids['enterprise'] ) );
+ $product->save();
+}
+
+$upgrade = new WC_Product_Grouped();
+$upgrade->set_name( 'Upgrade subscription' );
+$upgrade->set_slug( 'upgrade-subscription' );
+$upgrade->set_status( 'publish' );
+$upgrade->set_catalog_visibility( 'hidden' );
+$upgrade->set_children( array( $plan_ids['enterprise'], $plan_ids['professional'], $plan_ids['basic'] ) );
+$upgrade->update_meta_data( 'restrict_herself_upsells_switch', 'no' );
+$upgrade->save();
+
+wc_transaction_query( 'commit' );
diff --git a/tests/E2E/support/setup.sh b/tests/E2E/support/setup.sh
new file mode 100755
index 0000000..e3d4b31
--- /dev/null
+++ b/tests/E2E/support/setup.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+set -e
+
+compose="docker compose -f $(dirname "$0")/../docker-compose.yml"
+wp="$compose exec -T -u www-data wordpress wp"
+
+$compose up -d --wait
+
+if ! $wp core is-installed 2>/dev/null; then
+ $wp core install --url=http://localhost:8891 --title=LibreSign --admin_user=admin --admin_password=admin --admin_email=admin@example.com --skip-email
+ $wp core update --version=7.0.4
+ $wp core update-db
+ $compose restart wordpress
+ $compose up -d --wait wordpress
+fi
+
+$wp option update nextcloud_api_login admin
+$wp option update nextcloud_api_password admin
+$wp option update smtp_from noreply@example.com
+$wp theme activate libresign
+$wp plugin deactivate product-open-pricing-name-your-price-for-woocommerce
+$wp wc hpos enable --ignore-plugin-compatibility
+$wp rewrite structure '/%category%/%postname%/'
+$wp eval-file /e2e/seed.php
+$wp rewrite flush
diff --git a/tests/E2E/support/store.ts b/tests/E2E/support/store.ts
new file mode 100644
index 0000000..a22694f
--- /dev/null
+++ b/tests/E2E/support/store.ts
@@ -0,0 +1,103 @@
+import { randomUUID } from 'node:crypto';
+
+import { expect, Page } from '@playwright/test';
+
+import { mailTo } from './mailpit';
+
+export const STORE_URL = 'http://localhost:8891';
+
+export interface Customer {
+ email: string;
+ firstName: string;
+ lastName: string;
+ password: string;
+}
+
+export function newCustomer( firstName: string ): Customer {
+ return {
+ email: `${ firstName.toLowerCase() }-${ randomUUID().slice( 0, 8 ) }@example.com`,
+ firstName,
+ lastName: 'Silva',
+ password: `${ firstName }-Password-${ randomUUID().slice( 0, 8 ) }`,
+ };
+}
+
+export async function chooseThePlan( page: Page, plan: string, term: 'Monthly' | 'Yearly' ): Promise< void > {
+ await page.goto( `${ STORE_URL }/store/` );
+ await page.getByRole( 'link', { name: plan, exact: true } ).click();
+ const termSelect = page.locator( 'select[name="attribute_term-length"]' );
+ if ( ( await termSelect.inputValue() ) !== term.toLowerCase() ) {
+ await page.locator( '.cfvsw-swatches-option', { hasText: term } ).click();
+ }
+ await expect( termSelect ).toHaveValue( term.toLowerCase() );
+ await page.getByRole( 'button', { name: 'Sign up now' } ).click();
+ await expect( page.getByText( `“${ plan }” has been added to your cart.` ) ).toBeVisible();
+}
+
+async function fillTheBillingDetails( page: Page, customer: Customer ): Promise< void > {
+ await page.getByLabel( 'First name' ).fill( customer.firstName );
+ await page.getByLabel( 'Last name' ).fill( customer.lastName );
+ await page.getByLabel( 'Street address' ).fill( 'Rua da Assembleia, 10' );
+ await page.getByLabel( 'Town / City' ).fill( 'Rio de Janeiro' );
+ await page.getByLabel( 'Postcode / ZIP' ).fill( '20011-000' );
+ await page.getByLabel( 'CPF or CNPJ' ).fill( '111.444.777-35' );
+}
+
+export async function placeTheOrder( page: Page ): Promise< number > {
+ await page.getByRole( 'button', { name: 'Sign up now' } ).click();
+ await expect( page ).toHaveURL( /\/checkout\/order-received\/\d+\//, { timeout: 90_000 } );
+ return Number( page.url().match( /order-received\/(\d+)\// )![ 1 ] );
+}
+
+export async function signUp( page: Page, customer: Customer, plan: string, term: 'Monthly' | 'Yearly' ): Promise< number > {
+ await chooseThePlan( page, plan, term );
+ await page.goto( `${ STORE_URL }/checkout/` );
+ await page.getByLabel( 'Email address' ).fill( customer.email );
+ await fillTheBillingDetails( page, customer );
+ return placeTheOrder( page );
+}
+
+export async function subscriptionOf( page: Page ): Promise< number > {
+ const name = await page.getByRole( 'link', { name: /^View subscription number \d+$/ } ).first().getAttribute( 'aria-label' );
+ return Number( name!.match( /\d+/ )![ 0 ] );
+}
+
+export async function setThePasswordFromTheWelcomeMail( page: Page, customer: Customer ): Promise< void > {
+ const text = await mailTo( customer.email, 'Your LibreSign account has been created!' );
+ await page.goto( text.match( /\( (http\S+action=newaccount\S+) \)/ )![ 1 ] );
+ await page.locator( '#password_1' ).fill( customer.password );
+ await page.locator( '#password_2' ).fill( customer.password );
+ await page.getByRole( 'button', { name: 'Save' } ).click();
+ await expect( page.getByRole( 'alert' ).first() ).toContainText( 'Your password has been reset successfully.' );
+}
+
+export async function logInToTheStore( page: Page, login: string, password: string ): Promise< void > {
+ await page.context().clearCookies();
+ await page.goto( `${ STORE_URL }/account/` );
+ await page.getByLabel( 'Username or email address' ).fill( login );
+ await page.locator( '#password' ).fill( password );
+ await page.getByRole( 'button', { name: 'Log in' } ).click();
+ await expect( page.getByRole( 'link', { name: 'Log out' } ).first() ).toBeVisible();
+}
+
+export async function logInAsAdmin( page: Page ): Promise< void > {
+ await page.context().clearCookies();
+ await page.goto( `${ STORE_URL }/wp-login.php` );
+ await page.getByLabel( 'Username or Email Address' ).fill( 'admin' );
+ await page.getByLabel( 'Password', { exact: true } ).fill( 'admin' );
+ await page.getByRole( 'button', { name: 'Log In' } ).click();
+ await expect( page ).toHaveURL( /wp-admin/ );
+}
+
+export async function aSubscriber( page: Page, firstName: string ): Promise< { customer: Customer; subscriptionId: number } > {
+ const customer = newCustomer( firstName );
+ await signUp( page, customer, 'Basic', 'Monthly' );
+ const subscriptionId = await subscriptionOf( page );
+ await setThePasswordFromTheWelcomeMail( page, customer );
+ return { customer, subscriptionId };
+}
+
+export async function plansInTheStore( page: Page ): Promise< string[] > {
+ await page.goto( `${ STORE_URL }/store/` );
+ return page.locator( '.wp-block-post-title' ).allInnerTexts();
+}
diff --git a/tests/E2E/support/wordpress.ts b/tests/E2E/support/wordpress.ts
new file mode 100644
index 0000000..aeb54fa
--- /dev/null
+++ b/tests/E2E/support/wordpress.ts
@@ -0,0 +1,37 @@
+import { execFileSync } from 'node:child_process';
+import { join } from 'node:path';
+
+const COMPOSE_FILE = join( __dirname, '..', 'docker-compose.yml' );
+
+export function compose( ...args: string[] ): string {
+ return execFileSync( 'docker', [ 'compose', '-f', COMPOSE_FILE, ...args ], { encoding: 'utf8' } ).trim();
+}
+
+export function wp( ...args: string[] ): string {
+ return compose( 'exec', '-T', '-u', 'www-data', 'wordpress', 'wp', ...args );
+}
+
+export function wpEval( php: string ): string {
+ return wp( 'eval', php );
+}
+
+export function runScheduledSubscriptionAction( hook: 'woocommerce_scheduled_subscription_payment' | 'woocommerce_scheduled_subscription_end_of_prepaid_term', subscriptionId: number ): void {
+ wpEval( `do_action( '${ hook }', ${ subscriptionId } );` );
+}
+
+export function subscriptionStatus( subscriptionId: number ): string {
+ return wpEval( `echo wcs_get_subscription( ${ subscriptionId } )->get_status();` );
+}
+
+export function orderStatus( orderId: number ): string {
+ return wpEval( `echo wc_get_order( ${ orderId } )->get_status();` );
+}
+
+export function runTheScheduledNextcloudSyncRetry( orderId: number ): void {
+ wpEval( `
+ if ( ! as_next_scheduled_action( 'agm_retry_nextcloud_sync', array( 'order_id' => ${ orderId } ), 'nextcloud-admin-group-manager' ) ) {
+ throw new RuntimeException( 'No Nextcloud sync retry is scheduled for order ${ orderId }' );
+ }
+ do_action( 'agm_retry_nextcloud_sync', ${ orderId } );
+ ` );
+}