Skip to content

Commit 2f0cf63

Browse files
committed
INF-956/feat: add Playwright e2e tests for WooCommerce checkout with Two payment
1 parent 17bdc6c commit 2f0cf63

17 files changed

Lines changed: 519 additions & 1 deletion

.github/workflows/e2e-tests.yaml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: E2E Tests
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
id-token: write
11+
12+
concurrency:
13+
group: e2e-tests-${{ github.ref_name }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
e2e-tests:
18+
runs-on: ${{ vars.RUNNER_STANDARD }}
19+
timeout-minutes: 15
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Authenticate to Google Cloud
24+
id: gcp-auth
25+
uses: google-github-actions/auth@v2
26+
with:
27+
token_format: access_token
28+
service_account: ${{ vars.GCP_E2E_TESTS_SA_NAME_TWO_BETA }}
29+
workload_identity_provider: ${{ vars.WORKLOAD_IDENTITY_PROVIDER_PREFIX_TWO_BETA }}/${{ github.event.repository.name }}
30+
31+
- name: Set up Google Cloud SDK
32+
uses: google-github-actions/setup-gcloud@v2
33+
34+
- name: Fetch merchant API key
35+
id: secrets
36+
run: |
37+
MERCHANT_API_KEY=$(gcloud secrets versions access latest --secret="STAGING_SHOP_MERCHANT_API_KEY_TILLITTESTUK" --project=two-beta)
38+
echo "::add-mask::$MERCHANT_API_KEY"
39+
echo "MERCHANT_API_KEY=$MERCHANT_API_KEY" >> $GITHUB_OUTPUT
40+
41+
- name: Generate Docker plugin config
42+
run: |
43+
mkdir -p docker/config
44+
sed "s|\${MERCHANT_API_KEY}|${MERCHANT_API_KEY}|g" docker/config/staging-tillittestuk.json.tpl > docker/config/staging-tillittestuk.json
45+
env:
46+
MERCHANT_API_KEY: ${{ steps.secrets.outputs.MERCHANT_API_KEY }}
47+
48+
- name: Start WordPress
49+
run: docker compose up -d
50+
51+
- name: Wait for WordPress to be ready
52+
run: |
53+
for i in $(seq 1 60); do
54+
if curl -sf http://localhost:8888/ > /dev/null 2>&1; then
55+
echo "WordPress is ready"
56+
break
57+
fi
58+
echo "Waiting for WordPress... ($i/60)"
59+
sleep 5
60+
done
61+
curl -sf http://localhost:8888/ > /dev/null || (echo "WordPress failed to start" && docker compose logs && exit 1)
62+
63+
- uses: actions/setup-node@v4
64+
with:
65+
node-version: "22"
66+
67+
- name: Install e2e dependencies
68+
working-directory: tests/e2e
69+
run: npm ci && npx playwright install --with-deps chromium
70+
71+
- name: Run e2e tests
72+
working-directory: tests/e2e
73+
run: npx playwright test
74+
env:
75+
MERCHANT_API_KEY: ${{ steps.secrets.outputs.MERCHANT_API_KEY }}
76+
77+
- name: Upload test results
78+
if: failure()
79+
uses: actions/upload-artifact@v4
80+
with:
81+
name: playwright-report
82+
path: tests/e2e/test-results/
83+
retention-days: 7
84+
85+
- name: Stop WordPress
86+
if: always()
87+
run: docker compose down

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ vendor/
2727
coverage/
2828
.phpunit.result.cache
2929
.phpunit.cache/
30+
31+
# Playwright
32+
tests/e2e/test-results/
33+
tests/e2e/playwright-report/

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,12 @@ bumpver-%:
1414
patch: bumpver-patch
1515
minor: bumpver-minor
1616
major: bumpver-major
17+
18+
e2e-install:
19+
cd tests/e2e && npm install && npx playwright install chromium
20+
21+
e2e-test:
22+
cd tests/e2e && npx playwright test
23+
24+
e2e-test-headed:
25+
cd tests/e2e && npx playwright test --headed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"enabled": "yes",
3+
"title": "Business invoice %s days",
4+
"subtitle": "Receive the invoice via PDF and email",
5+
"test_checkout_host": "https://api.staging.two.inc",
6+
"clear_options_on_deactivation": "no",
7+
"section_api_credentials": "",
8+
"api_key": "${MERCHANT_API_KEY}",
9+
"section_checkout_options": "",
10+
"enable_order_intent": "yes",
11+
"add_field_department": "yes",
12+
"add_field_project": "yes",
13+
"add_field_purchase_order_number": "yes",
14+
"add_field_invoice_email": "yes",
15+
"show_abt_link": "yes",
16+
"invoice_fee_to_buyer": "no",
17+
"section_auto_complete_settings": "",
18+
"enable_company_search": "yes",
19+
"enable_company_search_for_others": "yes",
20+
"enable_address_lookup": "yes"
21+
}

docker/wpcli.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ until [ $counter -gt 4 ]; do
1313
random_price=$(shuf -i 100-200 -n 1)
1414
wp media import 'https://picsum.photos/600/400.jpg' --post_id=$product_id --title="Image for Product ${counter}" --featured_image
1515
wp post meta set $product_id _price $random_price
16+
wp post meta set $product_id _regular_price $random_price
17+
wp post meta set $product_id _stock_status instock
1618
counter=$(($counter + 1))
1719
done
1820
wp option update permalink_structure /%year%/%monthnum%/%day%/%postname%/
19-
#wp plugin activate tillit-payment-gateway
21+
wp plugin activate tillit-payment-gateway
2022
wp option update woocommerce_woocommerce-gateway-tillit_settings --format=json </opt/tillit-payment-gateway/${WOOCOM_PLUGIN_CONFIG_JSON:-docker/config/local.json}
2123
wp option update woocommerce_currency $WOOCOM_CURRENCY
2224
wp option update woocommerce_default_country $WOOCOM_DEFAULT_COUNTRY

docs/e2e-test-plan.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# INF-956: WooCommerce Plugin E2E Tests
2+
3+
Linear: https://linear.app/tillit/issue/INF-956
4+
5+
## Context
6+
7+
WooCommerce e2e tests were deleted from the e2e-tests repo during the API migration (INF-940). They now live in the woocommerce-plugin repo, running against the local Docker dev environment (WordPress + WooCommerce + Two plugin at localhost:8888).
8+
9+
Tests focus on plugin-specific behaviour: WooCommerce store checkout with Two payment, order lifecycle through WP admin, and Two API state verification.
10+
11+
## Docker environment
12+
13+
- Store: `http://localhost:8888` (Storefront theme, NL, EUR)
14+
- Admin: `http://localhost:8888/wp-admin` (admin / twoinb2b)
15+
- Products: "Product 1"–"Product 4", random prices 100–200
16+
- Plugin config: `docker/config/staging-tillittestuk.json``api.staging.two.inc`
17+
- Merchant: tillittestuk (UK, org 13078389) — has merchant-wide `skip_verification` rule
18+
19+
## Checklist
20+
21+
### Docker bootstrap
22+
23+
- [x] **`docker/wpcli.sh`**: Uncomment plugin activation (line 19)
24+
- [x] Add `_regular_price` and `_stock_status` meta so products display correctly in Storefront
25+
26+
### Test infrastructure (`tests/e2e/`)
27+
28+
- [x] `package.json` — Playwright Test, TypeScript
29+
- [x] `playwright.config.ts` — chromium, baseURL localhost:8888, trace/video on failure
30+
- [x] `config.ts` — reads `MERCHANT_API_KEY` from env var, defaults to staging Two API
31+
- [x] `checkout-api.ts` — Two API client (get order state, verify, confirm, cancel)
32+
- [x] `docker/config/staging-tillittestuk.json.tpl` — template for Docker plugin config, populated at CI time
33+
34+
### Page objects
35+
36+
- [x] `pages/store.ts` — add product to cart, go to checkout
37+
- [x] `pages/checkout.ts` — select Two payment, company search (Select2), billing details, place order
38+
- [x] `pages/wp-admin.ts` — login, navigate to orders, change status, refund
39+
40+
### Tests
41+
42+
- [x] `tests/order-flow.spec.ts` — place order → verify CONFIRMED → fulfil via WP admin → verify FULFILLED → refund → verify REFUNDED
43+
- [x] `tests/cancel-order.spec.ts` — place order → cancel via WP admin → verify CANCELLED
44+
- [x] `tests/max-limit.spec.ts` — excessive quantity → rejection on checkout
45+
46+
### CI/CD
47+
48+
- [x] `.github/workflows/e2e-tests.yaml` — GCP auth via WIF, fetch API key from Secret Manager, generate Docker config, start WordPress, run Playwright tests, upload artifacts on failure
49+
- [x] Makefile targets: `e2e-install`, `e2e-test`, `e2e-test-headed`
50+
51+
### Infrastructure
52+
53+
- [x] Generated new API key for tillittestuk merchant via `POST /admin/v1/merchant/{id}/api_key`
54+
- [x] Stored as `STAGING_SHOP_MERCHANT_API_KEY_TILLITTESTUK` in GCP Secret Manager (two-beta)
55+
- [x] `GCP_E2E_TESTS_SA_NAME_TWO_BETA` org var: added woocommerce-plugin to selected repos
56+
- [x] `WORKLOAD_IDENTITY_PROVIDER_PREFIX_TWO_BETA` org var: changed from "private" to "selected", added e2e-tests + woocommerce-plugin
57+
- [ ] Verify CI passes end-to-end
58+
59+
## Running locally
60+
61+
```bash
62+
docker compose up -d
63+
# wait for wpcli to finish bootstrapping (~60s)
64+
make e2e-install
65+
MERCHANT_API_KEY=secret_test_xxx make e2e-test
66+
```
67+
68+
The API key comes from your local `docker/config/staging-tillittestuk.json` (gitignored). In CI it's fetched from GCP Secret Manager.
69+
70+
## Out of scope
71+
72+
- Identity verification / SCA flow (covered in e2e-tests repo)
73+
- Merchant portal tests (covered in e2e-tests repo)
74+
- Multi-country support (single NL environment)

tests/e2e/checkout-api.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { API_BASE_URL, API_KEY } from "./config.js";
2+
3+
const headers = {
4+
"X-API-Key": API_KEY,
5+
"Content-Type": "application/json",
6+
};
7+
8+
export async function getOrder(orderId: string): Promise<Record<string, unknown>> {
9+
const res = await fetch(`${API_BASE_URL}/v1/order/${orderId}`, { headers });
10+
if (!res.ok) throw new Error(`get order failed: ${res.status}`);
11+
return res.json();
12+
}
13+
14+
export async function getOrderState(orderId: string): Promise<string> {
15+
const order = await getOrder(orderId);
16+
return order.status as string;
17+
}
18+
19+
export async function waitForOrderState(
20+
orderId: string,
21+
expectedState: string,
22+
timeoutMs = 30_000,
23+
intervalMs = 3_000
24+
): Promise<void> {
25+
const deadline = Date.now() + timeoutMs;
26+
let lastState = "";
27+
while (Date.now() < deadline) {
28+
lastState = await getOrderState(orderId);
29+
if (lastState === expectedState) return;
30+
await new Promise((r) => setTimeout(r, intervalMs));
31+
}
32+
throw new Error(`Order ${orderId} did not reach ${expectedState} within ${timeoutMs}ms (last: ${lastState})`);
33+
}
34+
35+
export async function verifyOrder(orderId: string): Promise<string> {
36+
const res = await fetch(`${API_BASE_URL}/test/verify_order/${orderId}`, {
37+
method: "POST",
38+
headers,
39+
});
40+
if (!res.ok) throw new Error(`verify order failed: ${res.status}`);
41+
const data = await res.json();
42+
return data.merchant_confirmation_url ?? "";
43+
}
44+
45+
export async function confirmOrder(orderId: string): Promise<void> {
46+
const res = await fetch(`${API_BASE_URL}/v1/order/${orderId}/confirm`, {
47+
method: "POST",
48+
headers,
49+
});
50+
if (!res.ok) throw new Error(`confirm order failed: ${res.status}`);
51+
}
52+
53+
export async function cancelOrder(orderId: string): Promise<void> {
54+
const res = await fetch(`${API_BASE_URL}/v1/order/${orderId}/cancel`, {
55+
method: "POST",
56+
headers,
57+
});
58+
if (!res.ok) throw new Error(`cancel order failed: ${res.status}`);
59+
}

tests/e2e/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const STORE_URL = "http://localhost:8888";
2+
export const ADMIN_URL = `${STORE_URL}/wp-admin`;
3+
export const ADMIN_USER = "admin";
4+
export const ADMIN_PASSWORD = "twoinb2b";
5+
6+
export const API_BASE_URL = process.env.TWO_API_BASE_URL ?? "https://api.staging.two.inc";
7+
export const API_KEY = process.env.MERCHANT_API_KEY ?? "";
8+
9+
export const BUYER_COMPANY = "Booking.com B.V.";
10+
export const RECIPIENT_EMAIL = "bot@two.inc";
11+
export const PHONE_NUMBER = "+31612345678";
12+
13+
export const DEFAULT_TIMEOUT = 15_000;
14+
export const LONG_TIMEOUT = 60_000;

tests/e2e/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "woocommerce-plugin-e2e",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"test": "playwright test",
7+
"test:headed": "playwright test --headed",
8+
"test:debug": "playwright test --debug"
9+
},
10+
"devDependencies": {
11+
"@playwright/test": "^1.49.0",
12+
"@types/node": "^22.0.0",
13+
"typescript": "^5.7.0"
14+
}
15+
}

tests/e2e/pages/checkout.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { type Page, expect } from "@playwright/test";
2+
3+
import { BUYER_COMPANY, LONG_TIMEOUT, PHONE_NUMBER, RECIPIENT_EMAIL } from "../config.js";
4+
5+
export async function selectTwoPayment(page: Page) {
6+
const radio = page.locator("#payment_method_woocommerce-gateway-tillit");
7+
await radio.waitFor({ state: "visible" });
8+
await radio.check();
9+
}
10+
11+
export async function fillCompanySearch(page: Page, companyName = BUYER_COMPANY) {
12+
const container = page.locator("#select2-billing_company_display-container");
13+
await container.waitFor({ state: "visible" });
14+
await container.click();
15+
16+
const searchInput = page.locator(".select2-search__field");
17+
await searchInput.fill(companyName);
18+
19+
const result = page.locator(".select2-results__option:not(.select2-results__message)").first();
20+
await result.waitFor({ state: "visible", timeout: LONG_TIMEOUT });
21+
await result.click();
22+
23+
await expect(page.locator("#billing_address_1")).not.toBeEmpty();
24+
}
25+
26+
export async function fillBillingDetails(page: Page, firstName: string, lastName: string) {
27+
await page.locator("#billing_first_name").fill(firstName);
28+
await page.locator("#billing_last_name").fill(lastName);
29+
await page.locator("#billing_email").fill(RECIPIENT_EMAIL);
30+
await page.locator("#billing_phone").fill(PHONE_NUMBER);
31+
}
32+
33+
export async function placeOrder(page: Page): Promise<string> {
34+
const responsePromise = page.waitForResponse(
35+
(r) => r.url().includes("/v1/user/verify-order/"),
36+
{ timeout: LONG_TIMEOUT }
37+
);
38+
39+
await page.locator("#place_order").click();
40+
41+
const response = await responsePromise;
42+
const data = await response.json();
43+
const orderId: string = data.order_id;
44+
45+
await page.waitForLoadState("load");
46+
await expect(page).toHaveURL(/\/checkout\/order-received\/|\/order-received\//, { timeout: LONG_TIMEOUT });
47+
48+
return orderId;
49+
}
50+
51+
export async function expectRejection(page: Page) {
52+
await expect(page.locator(".twoinc-pay-box").getByText(/unavailable/i)).toBeVisible({ timeout: LONG_TIMEOUT });
53+
}

0 commit comments

Comments
 (0)