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 } ); + ` ); +}