Commit 1512be0ff42 for woocommerce
commit 1512be0ff427f8d2a6cda13a2171288d9de6a1ad
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Fri Sep 4 17:09:59 2026 +0300
Add E2E coverage for the remaining Back in Stock Notifications signup and email branches (#68323)
* test: cover remaining BIS signup and email configuration branches
* test: make BIS signup describes self-contained and close admin contexts
* test: reap accounts created by BIS signups through a fixture
* test: assert refused BIS consent writes no signup, not just no account
* test: pin createAccountOnSignup off in the BIS email suites
* test: assert the BIS login prompt lands on the account page
* test: match the BIS account-page landing by pathname, not regex
diff --git a/plugins/woocommerce/changelog/68079-add-bis-e2e-signup-and-email-branch-coverage b/plugins/woocommerce/changelog/68079-add-bis-e2e-signup-and-email-branch-coverage
new file mode 100644
index 00000000000..ac0a979d739
--- /dev/null
+++ b/plugins/woocommerce/changelog/68079-add-bis-e2e-signup-and-email-branch-coverage
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add Playwright E2E coverage for the remaining Back in Stock Notifications signup and email branches (signups disabled, account creation on signup, requires-account login flow, logged-in email footer, frontend verify/unsubscribe notices, tampered and expired email links, invalid email/product/nonce rejections).
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
index d1548d21e09..cc75b8b122a 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
@@ -2,9 +2,18 @@
Covers the scenarios from the original plugin test plan that have a target in core:
-- `signing-up.spec.ts` — PDP form rendering + signup flow (logged-in, guest single-opt-in, guest double-opt-in, requires-account).
-- `receiving-confirmations.spec.ts` — verify email + verified email + unsubscribe flow (double opt-in).
-- `receiving-notifications.spec.ts` — back-in-stock email dispatch on restock + unsubscribe flow.
+- `signing-up.spec.ts` — PDP form rendering + signup flow across the settings
+ matrix: signups disabled, logged-in single opt-in (with the "Manage
+ notifications" CTA and the nonce rejection), guest single and double opt-in,
+ account creation on signup (consent checkbox, welcome email), and the
+ requires-account prompt through to a logged-in signup. Also the server-side
+ rejections for an invalid email and a tampered product id.
+- `receiving-confirmations.spec.ts` — verify email + verified email + unsubscribe
+ flow (double opt-in), the frontend verify/unsubscribe notices, the logged-in
+ footer of the verified email, and rejected verify links (tampered key, expired).
+- `receiving-notifications.spec.ts` — back-in-stock email dispatch on restock +
+ unsubscribe flow, the logged-in footer of the back-in-stock email, and a
+ rejected (tampered) unsubscribe link.
- `managing-notifications.spec.ts` — admin list rendering + Resend on PENDING + Resend guard on ACTIVE + admin Cancel.
- `variations.spec.ts` — variable products: the form following the selected
variation, signup against a variation, the variation's attributes in the
@@ -31,6 +40,29 @@ Covers the scenarios from the original plugin test plan that have a target in co
enabled (it defaults to `false`) and it resolves against the parent product on
a variable PDP, not the selected variation.
+## Configuration-branch notes
+
+- The signup nonce is only verified when the
+ `woocommerce_customer_stock_notifications_personalization_enabled` filter is
+ on and the shopper is logged in (or an account is required), so guest forms
+ survive HTML caching. The nonce test turns personalization on through the
+ test helper's `e2e-filters` cookie (`setFilterValue()`), which is why it runs
+ in the logged-in describe.
+- Verify-link expiry is a filter
+ (`woocommerce_customer_stock_notifications_verification_expiration_time_threshold`),
+ not an option, so `expireVerificationLinks()` sets it to a negative value
+ through the same cookie. Both tests clear the cookie afterwards with
+ `clearFilters()`.
+- The email templates fork on `$is_guest`, which is "the signup has no
+ `WP_User`", not "the shopper was logged out": a guest signup with an email
+ that already belongs to an account, or one that created an account on signup,
+ also takes the logged-in branch. The specs cover it through the shared
+ `customer` account (`signUpAsCustomer()`).
+- Account-creation tests register a real customer for the guest's address. The
+ address comes from the `accountEmail` fixture, whose teardown looks it up and
+ deletes any account it finds, so a failed assertion (or a retry with a fresh
+ address) doesn't leave the account behind.
+
## Skipped scenarios
Three scenarios from the original plugin test plan target features that didn't
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
index 6603bc64322..71d858f3cb9 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
@@ -3,18 +3,27 @@
*/
import { expect, request, tags } from '../../fixtures/fixtures';
import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { customer } from '../../test-data/data';
import {
+ BIS_EMAIL_FOOTER,
BIS_EMAIL_LINKS,
bisAdminListUrl,
+ bisEmailBody,
bisEmailSubject,
+ bisNotice,
+ corruptEmailLinkKey,
+ expireVerificationLinks,
getEmailLinkById,
+ openEmailInMailLog,
resetBISOptions,
setBISOptions,
+ signUpAsCustomer,
signUpAsGuest,
test,
uniqueGuestEmail,
} from '../../utils/back-in-stock-notifications';
import { expectEmail, expectEmailContent } from '../../utils/email';
+import { clearFilters } from '../../utils/filters';
test.describe(
'Back in Stock Notifications — receiving confirmations',
@@ -27,6 +36,7 @@ test.describe(
allowSignups: true,
doubleOptIn: true,
requireAccount: false,
+ createAccountOnSignup: false,
} );
} );
@@ -102,6 +112,11 @@ test.describe(
await page.goto( verifyLink );
+ // The link redirects to the shop with a notice naming the product.
+ await expect(
+ page.getByText( bisNotice.verified( product.name ) )
+ ).toBeVisible();
+
// Confirmation email should be dispatched after successful verification (RSM-438).
await expectEmail(
page,
@@ -130,6 +145,17 @@ test.describe(
expect( unsubscribeUrl.searchParams.get( 'utm_medium' ) ).toBe(
'email'
);
+
+ // A guest signup gets the unsubscribe wording in the footer, not
+ // the account-management one.
+ await openEmailInMailLog(
+ page,
+ email,
+ bisEmailSubject.verified( product.name )
+ );
+ await expect(
+ bisEmailBody( page ).locator( 'body' )
+ ).toContainText( BIS_EMAIL_FOOTER.guest );
} );
test( 'following the unsubscribe link cancels the notification', async ( {
@@ -157,6 +183,11 @@ test.describe(
);
await page.goto( unsubscribeLink );
+ // The shopper sees the outcome on the shop page they are sent to.
+ await expect(
+ page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ ).toBeVisible();
+
// Verify via the admin notifications list: the row should now show Cancelled.
await page.goto( bisAdminListUrl( product.id ) );
const row = page
@@ -165,5 +196,120 @@ test.describe(
await expect( row.getByText( /Cancelled/i ) ).toBeVisible();
} );
+
+ test( "a logged-in customer's confirmation email offers account management instead of unsubscribing", async ( {
+ page,
+ product,
+ browser,
+ } ) => {
+ await signUpAsCustomer( browser, product.permalink );
+
+ const verifyLink = await getEmailLinkById(
+ page,
+ customer.email,
+ bisEmailSubject.verify( product.name ),
+ BIS_EMAIL_LINKS.actionButton
+ );
+ await page.goto( verifyLink );
+
+ await openEmailInMailLog(
+ page,
+ customer.email,
+ bisEmailSubject.verified( product.name )
+ );
+
+ const body = bisEmailBody( page ).locator( 'body' );
+
+ // The templates fork on whether the signup belongs to an account;
+ // every other email test signs up as a guest, so this is the only
+ // place the account branch renders.
+ await expect( body ).toContainText( BIS_EMAIL_FOOTER.loggedIn );
+ await expect( body ).not.toContainText( BIS_EMAIL_FOOTER.guest );
+
+ // The wording changes; the link it wraps still has to be there.
+ await expect(
+ bisEmailBody( page ).locator(
+ `a${ BIS_EMAIL_LINKS.unsubscribe }`
+ )
+ ).toHaveAttribute( 'href', /email_link_action=unsubscribe/ );
+ } );
+
+ test( 'a verify link with a tampered key leaves the signup pending', async ( {
+ page,
+ product,
+ browser,
+ } ) => {
+ const email = uniqueGuestEmail( 'bis-confirm-bad-key' );
+
+ await signUpAsGuest( browser, product.permalink, email );
+
+ const verifyLink = await getEmailLinkById(
+ page,
+ email,
+ bisEmailSubject.verify( product.name ),
+ BIS_EMAIL_LINKS.actionButton
+ );
+
+ await page.goto( corruptEmailLinkKey( verifyLink ) );
+
+ // A rejected key is a no-op: no redirect to the shop, no notice.
+ await expect( page ).toHaveURL( /notification_id=/ );
+ await expect(
+ page.getByText( bisNotice.verified( product.name ) )
+ ).toHaveCount( 0 );
+
+ await page.goto( bisAdminListUrl( product.id ) );
+ const row = page
+ .getByRole( 'row' )
+ .filter( { has: page.getByText( email, { exact: true } ) } );
+ await expect( row.getByText( /Pending/i ) ).toBeVisible();
+
+ // Positive control: the untouched link still verifies, so the
+ // signup above stayed pending because of the key and not because
+ // verification is broken.
+ await page.goto( verifyLink );
+ await expect(
+ page.getByText( bisNotice.verified( product.name ) )
+ ).toBeVisible();
+ } );
+
+ test( 'an expired verify link is rejected', async ( {
+ page,
+ product,
+ browser,
+ } ) => {
+ const email = uniqueGuestEmail( 'bis-confirm-expired' );
+
+ await signUpAsGuest( browser, product.permalink, email );
+
+ const verifyLink = await getEmailLinkById(
+ page,
+ email,
+ bisEmailSubject.verify( product.name ),
+ BIS_EMAIL_LINKS.actionButton
+ );
+
+ await expireVerificationLinks( page );
+ await page.goto( verifyLink );
+
+ await expect( page ).toHaveURL( /notification_id=/ );
+ await expect(
+ page.getByText( bisNotice.verified( product.name ) )
+ ).toHaveCount( 0 );
+
+ await page.goto( bisAdminListUrl( product.id ) );
+ const row = page
+ .getByRole( 'row' )
+ .filter( { has: page.getByText( email, { exact: true } ) } );
+ await expect( row.getByText( /Pending/i ) ).toBeVisible();
+
+ // Positive control: back on the default threshold the very same
+ // link verifies, so the expiry filter is what rejected it.
+ await clearFilters( page );
+ await page.goto( verifyLink );
+ await expect(
+ page.getByText( bisNotice.verified( product.name ) )
+ ).toBeVisible();
+ } );
}
);
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
index f382444971c..4d6a5bccaab 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
@@ -3,14 +3,21 @@
*/
import { expect, request, tags } from '../../fixtures/fixtures';
import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { customer } from '../../test-data/data';
import {
+ BIS_EMAIL_FOOTER,
BIS_EMAIL_LINKS,
bisAdminListUrl,
+ bisEmailBody,
bisEmailSubject,
+ bisNotice,
+ corruptEmailLinkKey,
getEmailLinkById,
+ openEmailInMailLog,
resetBISOptions,
restockProduct,
setBISOptions,
+ signUpAsCustomer,
signUpAsGuest,
test,
triggerStockNotificationsBatch,
@@ -31,6 +38,7 @@ test.describe(
allowSignups: true,
doubleOptIn: false,
requireAccount: false,
+ createAccountOnSignup: false,
} );
} );
@@ -82,6 +90,48 @@ test.describe(
expect( linkUrl.toString() ).toBe(
new URL( product.permalink ).toString()
);
+
+ // A guest signup gets the unsubscribe wording in the footer.
+ await openEmailInMailLog(
+ page,
+ email,
+ bisEmailSubject.backInStock( product.name )
+ );
+ await expect(
+ bisEmailBody( page ).locator( 'body' )
+ ).toContainText( BIS_EMAIL_FOOTER.guest );
+ } );
+
+ test( "a logged-in customer's back-in-stock email offers account management instead of unsubscribing", async ( {
+ page,
+ product,
+ restApi,
+ browser,
+ } ) => {
+ await signUpAsCustomer( browser, product.permalink );
+
+ await restockProduct( restApi, product.id );
+ await triggerStockNotificationsBatch( page );
+
+ await openEmailInMailLog(
+ page,
+ customer.email,
+ bisEmailSubject.backInStock( product.name )
+ );
+
+ const body = bisEmailBody( page ).locator( 'body' );
+
+ // The template forks on whether the signup belongs to an account;
+ // the guest fork is what every other email test exercises.
+ await expect( body ).toContainText( BIS_EMAIL_FOOTER.loggedIn );
+ await expect( body ).not.toContainText( BIS_EMAIL_FOOTER.guest );
+
+ // The wording changes; the link it wraps still has to be there.
+ await expect(
+ bisEmailBody( page ).locator(
+ `a${ BIS_EMAIL_LINKS.unsubscribe }`
+ )
+ ).toHaveAttribute( 'href', /email_link_action=unsubscribe/ );
} );
test( 'unsubscribe link in the back-in-stock email cancels the notification', async ( {
@@ -111,11 +161,60 @@ test.describe(
);
await page.goto( unsubscribeLink );
+ // The shopper sees the outcome on the shop page they are sent to.
+ await expect(
+ page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ ).toBeVisible();
+
await page.goto( bisAdminListUrl( product.id ) );
const row = page
.getByRole( 'row' )
.filter( { has: page.getByText( email, { exact: true } ) } );
await expect( row.getByText( /Cancelled/i ) ).toBeVisible();
} );
+
+ test( 'an unsubscribe link with a tampered key changes nothing', async ( {
+ page,
+ product,
+ restApi,
+ browser,
+ } ) => {
+ const email = uniqueGuestEmail( 'bis-restock-bad-key' );
+
+ await signUpAsGuest( browser, product.permalink, email );
+
+ await restockProduct( restApi, product.id );
+ await triggerStockNotificationsBatch( page );
+
+ const unsubscribeLink = await getEmailLinkById(
+ page,
+ email,
+ bisEmailSubject.backInStock( product.name ),
+ BIS_EMAIL_LINKS.unsubscribe
+ );
+
+ await page.goto( corruptEmailLinkKey( unsubscribeLink ) );
+
+ // A rejected key is a no-op: no redirect to the shop, no notice.
+ await expect( page ).toHaveURL( /notification_id=/ );
+ await expect(
+ page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ ).toHaveCount( 0 );
+
+ await page.goto( bisAdminListUrl( product.id ) );
+ const row = page
+ .getByRole( 'row' )
+ .filter( { has: page.getByText( email, { exact: true } ) } );
+ await expect( row ).toHaveCount( 1 );
+ await expect( row.getByText( /Cancelled/i ) ).toHaveCount( 0 );
+
+ // Positive control: the untouched link still unsubscribes, so the
+ // row above survived because of the key and not because
+ // unsubscribing is broken.
+ await page.goto( unsubscribeLink );
+ await expect(
+ page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ ).toBeVisible();
+ } );
}
);
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts
index 6520bf4d0e3..b4d508a4577 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts
@@ -2,16 +2,24 @@
* Internal dependencies
*/
import { expect, request, tags } from '../../fixtures/fixtures';
-import { ADMIN_STATE_PATH, CUSTOMER_STATE_PATH } from '../../playwright.config';
+import { CUSTOMER_STATE_PATH } from '../../playwright.config';
+import { customer } from '../../test-data/data';
import {
+ bisConsentCheckbox,
bisEmailSubject,
+ bisFormLocator,
+ bisNotice,
+ bisTargetProductInput,
+ expectEmailAsAdmin,
+ expectNoSignupAsAdmin,
+ findCustomerByEmail,
resetBISOptions,
setBISOptions,
signUpOnProductPage,
test,
uniqueGuestEmail,
} from '../../utils/back-in-stock-notifications';
-import { expectEmail } from '../../utils/email';
+import { clearFilters, setFilterValue } from '../../utils/filters';
test.describe(
'Back in Stock Notifications — signing up',
@@ -21,6 +29,44 @@ test.describe(
await resetBISOptions( request, baseURL! );
} );
+ test.describe( 'Signups disabled', () => {
+ test.beforeAll( async ( { baseURL } ) => {
+ await setBISOptions( request, baseURL!, {
+ allowSignups: true,
+ doubleOptIn: false,
+ requireAccount: false,
+ createAccountOnSignup: false,
+ } );
+ } );
+
+ test( 'turning signups off removes the form from the product page', async ( {
+ page,
+ product,
+ baseURL,
+ } ) => {
+ // Positive control: the form is there while signups are on, so
+ // a product page that renders no form for an unrelated reason
+ // cannot pass the assertion below.
+ await page.goto( product.permalink );
+ await expect( bisFormLocator( page ) ).toHaveCount( 1 );
+
+ // Flipped inside the test rather than in `beforeAll` so the
+ // control above and the assertion below run against the same
+ // product. The next describe's `beforeAll` sets it back.
+ await setBISOptions( request, baseURL!, {
+ allowSignups: false,
+ } );
+
+ await page.goto( product.permalink );
+ await expect( bisFormLocator( page ) ).toHaveCount( 0 );
+ await expect(
+ page.getByRole( 'heading', {
+ name: /Want to be notified when this product is back in stock\?/i,
+ } )
+ ).toHaveCount( 0 );
+ } );
+ } );
+
test.describe( 'Logged-in customer, single opt-in', () => {
test.use( { storageState: CUSTOMER_STATE_PATH } );
@@ -29,6 +75,7 @@ test.describe(
allowSignups: true,
doubleOptIn: false,
requireAccount: false,
+ createAccountOnSignup: false,
} );
} );
@@ -55,7 +102,7 @@ test.describe(
).toBeVisible();
} );
- test( 'submitting the form surfaces a success notice', async ( {
+ test( 'submitting the form surfaces a success notice with a link to manage notifications', async ( {
page,
product,
} ) => {
@@ -63,8 +110,14 @@ test.describe(
await signUpOnProductPage( page );
await expect(
- page.getByText( /You have successfully signed up/i )
+ page.getByText( bisNotice.success( product.name ) )
).toBeVisible();
+
+ // Logged-in signups get a "Manage notifications" CTA in front
+ // of the notice, pointing at the account endpoint.
+ await expect(
+ page.getByRole( 'link', { name: 'Manage notifications' } )
+ ).toHaveAttribute( 'href', /stock-notifications/ );
} );
test( 'a repeated signup surfaces the "already joined" notice', async ( {
@@ -78,7 +131,7 @@ test.describe(
// below can't race it. This is the first signup for a freshly
// created product, so it always succeeds.
await expect(
- page.getByText( /You have successfully signed up/i )
+ page.getByText( bisNotice.success( product.name ) )
).toBeVisible();
// Submitting the form a second time (the "already joined"
@@ -89,9 +142,49 @@ test.describe(
await page.goto( product.permalink );
await signUpOnProductPage( page );
await expect(
- page.getByText( /You have already joined this waitlist/i )
+ page.getByText( bisNotice.alreadyJoined )
).toBeVisible();
} );
+
+ test( 'a missing nonce is rejected once nonce checks are on', async ( {
+ page,
+ product,
+ } ) => {
+ // Core only verifies the signup nonce when personalization is
+ // on and the shopper is logged in (or an account is required),
+ // so guest forms survive HTML caching. Turn personalization on
+ // through the test helper's filter cookie to reach that branch.
+ await setFilterValue(
+ page,
+ 'woocommerce_customer_stock_notifications_personalization_enabled',
+ true
+ );
+
+ await page.goto( product.permalink );
+
+ // Blank the nonce the form posts, the way a stale cached page
+ // or a forged request would.
+ await page
+ .locator( 'input[name="wc_bis_nonce"]' )
+ .evaluate( ( input: HTMLInputElement ) => {
+ input.value = '';
+ } );
+ await signUpOnProductPage( page );
+
+ await expect(
+ page.getByText( bisNotice.errors.failed )
+ ).toBeVisible();
+
+ // Positive control on the same page: with the nonce intact the
+ // signup goes through, so the rejection above was the nonce.
+ await page.goto( product.permalink );
+ await signUpOnProductPage( page );
+ await expect(
+ page.getByText( bisNotice.success( product.name ) )
+ ).toBeVisible();
+
+ await clearFilters( page );
+ } );
} );
test.describe( 'Guest — single opt-in', () => {
@@ -100,6 +193,7 @@ test.describe(
allowSignups: true,
doubleOptIn: false,
requireAccount: false,
+ createAccountOnSignup: false,
} );
} );
@@ -117,6 +211,60 @@ test.describe(
await expect(
page.getByRole( 'button', { name: /Notify me/i } )
).toBeVisible();
+
+ // The consent checkbox only belongs to the account-creation
+ // setup, which is off here.
+ await expect( bisConsentCheckbox( page ) ).toHaveCount( 0 );
+ } );
+
+ test( 'submitting the form surfaces a success notice', async ( {
+ page,
+ product,
+ } ) => {
+ await page.goto( product.permalink );
+ await signUpOnProductPage( page, {
+ email: uniqueGuestEmail( 'bis-guest-single' ),
+ } );
+
+ await expect(
+ page.getByText( bisNotice.success( product.name ) )
+ ).toBeVisible();
+ } );
+
+ test( 'an invalid email address is rejected', async ( {
+ page,
+ product,
+ } ) => {
+ await page.goto( product.permalink );
+
+ // The form opts out of browser validation (`novalidate`), so
+ // the malformed address reaches the server and it is the
+ // server's rejection that renders.
+ await signUpOnProductPage( page, { email: 'not-an-email' } );
+
+ await expect(
+ page.getByText( bisNotice.errors.invalidEmail )
+ ).toBeVisible();
+ } );
+
+ test( 'a product id that does not exist is rejected', async ( {
+ page,
+ product,
+ } ) => {
+ await page.goto( product.permalink );
+
+ await bisTargetProductInput( page ).evaluate(
+ ( input: HTMLInputElement ) => {
+ input.value = '999999999';
+ }
+ );
+ await signUpOnProductPage( page, {
+ email: uniqueGuestEmail( 'bis-guest-bad-product' ),
+ } );
+
+ await expect(
+ page.getByText( bisNotice.errors.invalidProduct )
+ ).toBeVisible();
} );
} );
@@ -126,10 +274,10 @@ test.describe(
allowSignups: true,
doubleOptIn: true,
requireAccount: false,
+ createAccountOnSignup: false,
} );
} );
- // eslint-disable-next-line playwright/expect-expect -- `expectEmail()` asserts on the mail log.
test( 'submitting signup dispatches a verification email', async ( {
page,
product,
@@ -140,18 +288,123 @@ test.describe(
await page.goto( product.permalink );
await signUpOnProductPage( page, { email } );
- // Switch to an admin context to inspect the mail log —
- // WP Mail Logging is an admin-only screen.
- const adminContext = await browser.newContext( {
- storageState: ADMIN_STATE_PATH,
- } );
- const adminPage = await adminContext.newPage();
- await expectEmail(
- adminPage,
+ await expect(
+ page.getByText( bisNotice.doubleOptIn )
+ ).toBeVisible();
+
+ // WP Mail Logging is an admin-only screen, so the log is read
+ // from a separate admin context.
+ await expectEmailAsAdmin(
+ browser,
email,
bisEmailSubject.verify( product.name )
);
- await adminContext.close();
+ } );
+ } );
+
+ test.describe( 'Guest — create account on signup', () => {
+ test.describe( 'Single opt-in', () => {
+ test.beforeAll( async ( { baseURL } ) => {
+ await setBISOptions( request, baseURL!, {
+ allowSignups: true,
+ doubleOptIn: false,
+ requireAccount: false,
+ createAccountOnSignup: true,
+ } );
+ } );
+
+ test( 'the consent checkbox renders and the signup is refused until it is ticked', async ( {
+ page,
+ product,
+ restApi,
+ browser,
+ accountEmail: email,
+ } ) => {
+ await page.goto( product.permalink );
+
+ const consent = bisConsentCheckbox( page );
+ await expect( consent ).toBeVisible();
+ await expect( consent ).not.toBeChecked();
+
+ await signUpOnProductPage( page, { email } );
+
+ await expect(
+ page.getByText( bisNotice.errors.missingConsent )
+ ).toBeVisible();
+
+ // The refusal has to happen before anything is written:
+ // no account for the address, and no signup either.
+ expect(
+ await findCustomerByEmail( restApi, email )
+ ).toBeUndefined();
+ await expectNoSignupAsAdmin( browser, product.id, email );
+ } );
+
+ test( 'ticking consent signs up, registers an account and sends the welcome email', async ( {
+ page,
+ product,
+ restApi,
+ browser,
+ accountEmail: email,
+ } ) => {
+ await page.goto( product.permalink );
+ await signUpOnProductPage( page, { email, consent: true } );
+
+ await expect(
+ page.getByText(
+ bisNotice.accountCreated( product.name )
+ )
+ ).toBeVisible();
+
+ expect(
+ await findCustomerByEmail( restApi, email )
+ ).toBeDefined();
+
+ // The "check your e-mail for details" in the notice is
+ // WooCommerce's own new-account email, sent with the
+ // generated password.
+ await expectEmailAsAdmin(
+ browser,
+ email,
+ /account has been created!/
+ );
+ } );
+ } );
+
+ test.describe( 'Double opt-in', () => {
+ test.beforeAll( async ( { baseURL } ) => {
+ await setBISOptions( request, baseURL!, {
+ allowSignups: true,
+ doubleOptIn: true,
+ requireAccount: false,
+ createAccountOnSignup: true,
+ } );
+ } );
+
+ test( 'ticking consent registers an account and still asks for email verification', async ( {
+ page,
+ product,
+ restApi,
+ browser,
+ accountEmail: email,
+ } ) => {
+ await page.goto( product.permalink );
+ await signUpOnProductPage( page, { email, consent: true } );
+
+ await expect(
+ page.getByText( bisNotice.accountCreatedDoubleOptIn )
+ ).toBeVisible();
+
+ expect(
+ await findCustomerByEmail( restApi, email )
+ ).toBeDefined();
+
+ await expectEmailAsAdmin(
+ browser,
+ email,
+ bisEmailSubject.verify( product.name )
+ );
+ } );
} );
} );
@@ -161,6 +414,7 @@ test.describe(
allowSignups: true,
doubleOptIn: false,
requireAccount: true,
+ createAccountOnSignup: false,
} );
} );
@@ -175,13 +429,57 @@ test.describe(
name: /Email address to be notified/i,
} )
).toHaveCount( 0 );
+ await expect(
+ page.getByRole( 'button', { name: /Notify me/i } )
+ ).toHaveCount( 0 );
// Pair the absence with the prompt core renders in its place,
// so a 404 or a failed render can't pass as account gating.
await expect(
- page.getByText(
- /Please log in to sign up for stock notifications/i
- )
+ page.getByText( bisNotice.accountRequired )
+ ).toBeVisible();
+ } );
+
+ test( 'logging in from the prompt lets the customer sign up', async ( {
+ page,
+ product,
+ } ) => {
+ await page.goto( product.permalink );
+
+ await page.getByRole( 'link', { name: 'log in' } ).click();
+
+ // The prompt's link points at a non-existent endpoint, so
+ // WordPress guesses the 404 back to the account page. Assert
+ // the landing, or a lost guess would only show up as a
+ // timeout on the login form below.
+ await expect( page ).toHaveURL( ( url ) => {
+ const path = url.pathname.endsWith( '/' )
+ ? url.pathname.slice( 0, -1 )
+ : url.pathname;
+
+ return path.endsWith( '/my-account' );
+ } );
+
+ await page.locator( '#username' ).fill( customer.username );
+ await page.locator( '#password' ).fill( customer.password );
+ await page
+ .getByRole( 'button', { name: 'Log in', exact: true } )
+ .click();
+
+ // Login lands on the account dashboard, so go back to the
+ // product to find the form now rendered for the customer.
+ await page.goto( product.permalink );
+ await expect(
+ page.getByText( bisNotice.accountRequired )
+ ).toHaveCount( 0 );
+
+ await signUpOnProductPage( page );
+
+ await expect(
+ page.getByText( bisNotice.success( product.name ) )
+ ).toBeVisible();
+ await expect(
+ page.getByRole( 'link', { name: 'Manage notifications' } )
).toBeVisible();
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
index bafb4e4f6d7..f3ef921f239 100644
--- a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -13,9 +13,11 @@ import {
*/
import { deleteOption, setOption } from './options';
import { expectEmail } from './email';
+import { setFilterValue } from './filters';
import { wpCLI } from './cli';
import { expect, test as baseTest } from '../fixtures/fixtures';
import { admin } from '../test-data/data';
+import { ADMIN_STATE_PATH, CUSTOMER_STATE_PATH } from '../playwright.config';
/**
* Names of the Back in Stock Notifications options in core.
@@ -27,6 +29,8 @@ export const BIS_OPTIONS = {
doubleOptIn:
'woocommerce_customer_stock_notifications_require_double_opt_in',
requireAccount: 'woocommerce_customer_stock_notifications_require_account',
+ createAccountOnSignup:
+ 'woocommerce_customer_stock_notifications_create_account_on_signup',
} as const;
/**
@@ -83,12 +87,13 @@ export async function assertBISEnvReady(): Promise< void > {
/**
* Configure the BIS feature options for a test. Omitted keys are left untouched.
*
- * @param {APIRequest} request Playwright request fixture.
- * @param {string} baseURL Test site base URL.
- * @param {Object} options BIS option toggles.
- * @param {boolean} [options.allowSignups] Whether the signup form is rendered on product pages.
- * @param {boolean} [options.doubleOptIn] Whether signups require email verification before activating.
- * @param {boolean} [options.requireAccount] Whether signups are limited to logged-in users.
+ * @param {APIRequest} request Playwright request fixture.
+ * @param {string} baseURL Test site base URL.
+ * @param {Object} options BIS option toggles.
+ * @param {boolean} [options.allowSignups] Whether the signup form is rendered on product pages.
+ * @param {boolean} [options.doubleOptIn] Whether signups require email verification before activating.
+ * @param {boolean} [options.requireAccount] Whether signups are limited to logged-in users.
+ * @param {boolean} [options.createAccountOnSignup] Whether a guest signup also registers a customer account.
*/
export async function setBISOptions(
request: APIRequest,
@@ -97,6 +102,7 @@ export async function setBISOptions(
allowSignups?: boolean;
doubleOptIn?: boolean;
requireAccount?: boolean;
+ createAccountOnSignup?: boolean;
}
): Promise< void > {
const toYesNo = ( v: boolean | undefined ): string | undefined => {
@@ -110,6 +116,10 @@ export async function setBISOptions(
[ BIS_OPTIONS.allowSignups, toYesNo( options.allowSignups ) ],
[ BIS_OPTIONS.doubleOptIn, toYesNo( options.doubleOptIn ) ],
[ BIS_OPTIONS.requireAccount, toYesNo( options.requireAccount ) ],
+ [
+ BIS_OPTIONS.createAccountOnSignup,
+ toYesNo( options.createAccountOnSignup ),
+ ],
];
for ( const [ name, value ] of entries ) {
@@ -478,17 +488,31 @@ export async function selectVariation(
);
}
+/**
+ * Locator for the account-creation consent checkbox on the PDP sign-up form.
+ *
+ * Its label is the store's registration privacy text, which the merchant can
+ * edit, so it is located by name rather than by that label.
+ *
+ * @param {Page} page Playwright page on the product detail.
+ */
+export function bisConsentCheckbox( page: Page ) {
+ return page.locator( 'input[name="wc_bis_opt_in"]' );
+}
+
/**
* Submit the PDP sign-up form. Caller must already have the product page loaded.
*
- * @param {Page} page Playwright page on the product detail.
- * @param {Object} [opts] Fill options.
- * @param {string} [opts.email] Email address to enter (guest flow only; logged-in PDP hides the field).
+ * @param {Page} page Playwright page on the product detail.
+ * @param {Object} [opts] Fill options.
+ * @param {string} [opts.email] Email address to enter (guest flow only; logged-in PDP hides the field).
+ * @param {boolean} [opts.consent] Tick the account-creation consent checkbox (only rendered with `createAccountOnSignup`).
*/
export async function signUpOnProductPage(
page: Page,
opts: {
email?: string;
+ consent?: boolean;
} = {}
): Promise< void > {
if ( opts.email !== undefined ) {
@@ -499,9 +523,81 @@ export async function signUpOnProductPage(
.fill( opts.email );
}
+ if ( opts.consent ) {
+ await bisConsentCheckbox( page ).check();
+ }
+
await page.getByRole( 'button', { name: /Notify me/i } ).click();
}
+/**
+ * Submit the PDP signup form in a fresh browser context and wait for the success notice.
+ *
+ * Runs in its own context so the caller's page (usually an admin session that
+ * goes on to read the mail log) is left untouched.
+ *
+ * @param {Browser} browser The test's browser fixture.
+ * @param {string} permalink The product permalink.
+ * @param {Object} opts Signup options.
+ * @param {Object} [opts.storageState] Storage state for the signup context; a logged-out guest by default.
+ * @param {string} [opts.email] Email to enter; omit for a logged-in signup, where the field isn't rendered.
+ * @param {boolean} [opts.consent] Tick the account-creation consent checkbox before submitting.
+ * @param {RegExp} [opts.expectedNotice] Notice to wait for after the post; a generic success match by default.
+ * @param {Object} [opts.selectVariation] Variation to pick before submitting, for variable products.
+ * @param {Object} [opts.selectVariation.product] The variable product handle.
+ * @param {Object} [opts.selectVariation.variation] The variation to select.
+ */
+export async function signUpInNewContext(
+ browser: Browser,
+ permalink: string,
+ opts: {
+ storageState?: string | { cookies: []; origins: [] };
+ email?: string;
+ consent?: boolean;
+ expectedNotice?: RegExp;
+ selectVariation?: {
+ product: BISVariableProduct;
+ variation: BISVariation;
+ };
+ } = {}
+): Promise< void > {
+ const context = await browser.newContext( {
+ storageState: opts.storageState ?? { cookies: [], origins: [] },
+ } );
+ const page = await context.newPage();
+
+ // Closed in `finally`: these specs run on a single worker, so a context
+ // left open by a failed signup would otherwise outlive the test.
+ try {
+ await page.goto( permalink );
+
+ if ( opts.selectVariation ) {
+ await selectVariation(
+ page,
+ opts.selectVariation.product,
+ opts.selectVariation.variation
+ );
+ }
+
+ await signUpOnProductPage( page, {
+ email: opts.email,
+ consent: opts.consent,
+ } );
+
+ // The form posts and reloads the PDP with a notice. Wait for that notice
+ // before closing the context, or the submission can be aborted mid-flight
+ // and the spec fails later, looking like a missing email.
+ await expect(
+ page.getByText(
+ opts.expectedNotice ??
+ /You have successfully signed up|Thanks for signing up/i
+ )
+ ).toBeVisible();
+ } finally {
+ await context.close();
+ }
+}
+
/**
* Submit the PDP signup form as a logged-out guest, regardless of the test's storageState.
*
@@ -524,34 +620,110 @@ export async function signUpAsGuest(
};
} = {}
): Promise< void > {
- const guestContext = await browser.newContext( {
- storageState: { cookies: [], origins: [] },
+ await signUpInNewContext( browser, permalink, { email, ...opts } );
+}
+
+/**
+ * Submit the PDP signup form as the shared logged-in customer, regardless of the test's storageState.
+ *
+ * The signup binds to the customer's account, which is what makes the emails
+ * take their logged-in branch.
+ *
+ * @param {Browser} browser The test's browser fixture.
+ * @param {string} permalink The product permalink.
+ */
+export async function signUpAsCustomer(
+ browser: Browser,
+ permalink: string
+): Promise< void > {
+ await signUpInNewContext( browser, permalink, {
+ storageState: CUSTOMER_STATE_PATH,
} );
- const guestPage = await guestContext.newPage();
- await guestPage.goto( permalink );
-
- if ( opts.selectVariation ) {
- await selectVariation(
- guestPage,
- opts.selectVariation.product,
- opts.selectVariation.variation
- );
- }
+}
+
+/**
+ * Find the customer account registered for an email address, if any.
+ *
+ * @param {ApiClient} restApi WP REST client.
+ * @param {string} email The email address.
+ */
+export async function findCustomerByEmail(
+ restApi: ApiClient,
+ email: string
+): Promise< BISCustomer | undefined > {
+ const response = await restApi.get< BISCustomer[] >(
+ `${ WC_API_PATH }/customers`,
+ {
+ email,
+ role: 'all',
+ }
+ );
- await signUpOnProductPage( guestPage, { email } );
+ return response.data.find(
+ ( customer: BISCustomer ) => customer.email === email
+ );
+}
- // The form posts and reloads the PDP with a notice. Wait for that notice
- // before closing the context, or the submission can be aborted mid-flight
- // and the spec fails later, looking like a missing email.
- await expect(
- guestPage.getByText(
- /You have successfully signed up|Thanks for signing up/i
- )
- ).toBeVisible();
+/**
+ * A customer account, as far as these specs need to know it.
+ */
+type BISCustomer = { id: number; email: string; username: string };
+
+/**
+ * Permanently delete a customer account created by a signup.
+ *
+ * @param {ApiClient} restApi WP REST client.
+ * @param {number} customerId The customer id.
+ */
+export async function deleteCustomer(
+ restApi: ApiClient,
+ customerId: number
+): Promise< void > {
+ await restApi.delete( `${ WC_API_PATH }/customers/${ customerId }`, {
+ force: true,
+ } );
+}
+
+/**
+ * Make every verification link look expired to the server for this page's context.
+ *
+ * Expiry is filter-driven rather than an option, so it is set through the
+ * `e2e-filters` cookie the test helper plugin reads. A negative threshold
+ * makes `time() - timestamp > threshold` true for any link, however fresh.
+ *
+ * @param {Page} page Playwright page whose context will follow the link.
+ */
+export async function expireVerificationLinks( page: Page ): Promise< void > {
+ await setFilterValue(
+ page,
+ 'woocommerce_customer_stock_notifications_verification_expiration_time_threshold',
+ -1
+ );
+}
- await guestContext.close();
+/**
+ * Replace the action key in an email link with one that cannot match.
+ *
+ * @param {string} link The verify or unsubscribe link from the email.
+ */
+export function corruptEmailLinkKey( link: string ): string {
+ const url = new URL( link );
+ url.searchParams.set( 'email_link_action_key', 'not-the-real-key' );
+ return url.toString();
}
+/**
+ * Text the email footer renders for a signup bound to an account.
+ *
+ * @see templates/emails/customer-stock-notification.php
+ * @see templates/emails/customer-stock-notification-verified.php
+ */
+export const BIS_EMAIL_FOOTER = {
+ loggedIn:
+ /To manage your notifications, click here to log in to your account\./,
+ guest: /To stop receiving these messages, click here to unsubscribe\./,
+} as const;
+
/**
* Build the admin notifications-list URL, optionally filtered to one product.
*
@@ -564,6 +736,50 @@ export function bisAdminListUrl( productId: number ): string {
return `wp-admin/admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=${ productId }`;
}
+/**
+ * Assert the product's notifications list holds no row for an email address.
+ *
+ * Opens its own admin context because the list is an admin-only screen, and
+ * closes it in `finally` so a failure doesn't leak a context into the run.
+ *
+ * @param {Browser} browser The test's browser fixture.
+ * @param {number} productId Product the list is filtered by.
+ * @param {string} email The signup email address that must not be listed.
+ */
+export async function expectNoSignupAsAdmin(
+ browser: Browser,
+ productId: number,
+ email: string
+): Promise< void > {
+ const adminContext = await browser.newContext( {
+ storageState: ADMIN_STATE_PATH,
+ } );
+
+ try {
+ const adminPage = await adminContext.newPage();
+ await adminPage.goto( bisAdminListUrl( productId ) );
+
+ await expect(
+ adminPage.getByRole( 'row' ).filter( {
+ has: adminPage.getByText( email, { exact: true } ),
+ } )
+ ).toHaveCount( 0 );
+ } finally {
+ await adminContext.close();
+ }
+}
+
+/**
+ * Generate a unique guest email address for a test so mail-log assertions don't collide.
+ *
+ * @param {string} prefix Short descriptor of the test.
+ */
+export function uniqueGuestEmail( prefix = 'bis' ): string {
+ return `${ prefix }-${ Date.now() }-${ Math.floor(
+ Math.random() * 1000
+ ) }@example.com`;
+}
+
/**
* Ids of products created by the `product` fixture, deleted in one batch when
* the worker finishes. A per-test DELETE costs ~0.45s, which is pure overhead
@@ -602,6 +818,7 @@ export const test = baseTest.extend<
product: BISProduct;
variableProduct: BISVariableProduct;
anyAttributeVariableProduct: BISVariableProduct;
+ accountEmail: string;
},
{ bisEnvReady: void }
>( {
@@ -655,6 +872,24 @@ export const test = baseTest.extend<
await use( product );
productsToReap.push( product.id );
},
+
+ /**
+ * A guest email address that a signup may register an account for.
+ *
+ * Teardown looks the address up and deletes the account if one exists, so
+ * the customer is reaped whether the test failed on the notice, on the
+ * lookup, or on the email — the test itself never has to hold the id.
+ */
+ accountEmail: async ( { restApi }, use ) => {
+ const email = uniqueGuestEmail( 'bis-account' );
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright's fixture `use`, not a React hook.
+ await use( email );
+
+ const account = await findCustomerByEmail( restApi, email );
+ if ( account ) {
+ await deleteCustomer( restApi, account.id );
+ }
+ },
} );
/**
@@ -706,6 +941,79 @@ export function escapeRegExp( value: string ): string {
return value.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
}
+/**
+ * Sign-up notices core prints on the PDP after the form posts.
+ *
+ * Success notices are bound to the product name where core interpolates it,
+ * so a notice for the wrong product fails instead of passing.
+ *
+ * @see SignupService::get_signup_user_message()
+ * @see SignupService::get_error_message()
+ * @see EmailActionController
+ */
+export const bisNotice = {
+ /**
+ * Single opt-in success.
+ *
+ * @param {string} productName The product name.
+ */
+ success: ( productName: string ): RegExp =>
+ new RegExp(
+ `You have successfully signed up! You will be notified when "${ escapeRegExp(
+ productName
+ ) }" is back in stock\\.`
+ ),
+ doubleOptIn:
+ /Thanks for signing up! Please complete the sign-up process by following the verification link sent to your e-mail\./,
+ /**
+ * Single opt-in success where the signup also registered an account.
+ *
+ * @param {string} productName The product name.
+ */
+ accountCreated: ( productName: string ): RegExp =>
+ new RegExp(
+ `You have successfully signed up and will be notified when "${ escapeRegExp(
+ productName
+ ) }" is back in stock! Note that a new account has been created for you; please check your e-mail for details\\.`
+ ),
+ accountCreatedDoubleOptIn:
+ /Thanks for signing up! An account has been created for you\. Please complete the sign-up process by following the verification link sent to your e-mail\./,
+ alreadyJoined: /You have already joined this waitlist\./,
+ accountRequired: /Please log in to sign up for stock notifications\./,
+ /**
+ * Printed on the shop page after a verify link is followed.
+ *
+ * @param {string} productName The product name.
+ */
+ verified: ( productName: string ): RegExp =>
+ new RegExp(
+ `Successfully verified stock notifications for "${ escapeRegExp(
+ productName
+ ) }"\\.`
+ ),
+ /**
+ * Printed on the shop page after an unsubscribe link is followed.
+ *
+ * @param {string} email The unsubscribed email address.
+ * @param {string} productName The product name.
+ */
+ unsubscribed: ( email: string, productName: string ): RegExp =>
+ new RegExp(
+ `Successfully unsubscribed ${ escapeRegExp(
+ email
+ ) }\\. You will not receive a notification when "${ escapeRegExp(
+ productName
+ ) }" becomes available\\.`
+ ),
+ errors: {
+ invalidEmail: /Invalid email address\./,
+ invalidProduct: /Invalid product\./,
+ missingConsent:
+ /To proceed, please consent to the creation of a new account with your e-mail\./,
+ failed: /Failed to sign up\. Please try again\./,
+ },
+} as const;
+
/**
* Subject matchers for the three BIS emails, bound to a specific product.
*
@@ -743,6 +1051,41 @@ export const bisEmailSubject = {
subjectMatcher( `"${ productName }" is back in stock!` ),
} as const;
+/**
+ * Assert an email landed in the mail log, from a throwaway admin context.
+ *
+ * For specs whose own page is a guest or customer session: WP Mail Logging
+ * is an admin-only screen. The context is closed in `finally` so a missing
+ * email fails the test without leaking a context into the rest of the run.
+ *
+ * @param {Browser} browser The test's browser fixture.
+ * @param {string} receiverEmailAddress The recipient email address.
+ * @param {RegExp} subject The email subject (regular expression).
+ * @param {number} [expectedCount] Expected number of matching rows. Defaults to 1.
+ */
+export async function expectEmailAsAdmin(
+ browser: Browser,
+ receiverEmailAddress: string,
+ subject: RegExp,
+ expectedCount = 1
+): Promise< void > {
+ const adminContext = await browser.newContext( {
+ storageState: ADMIN_STATE_PATH,
+ } );
+
+ try {
+ const adminPage = await adminContext.newPage();
+ await expectEmail(
+ adminPage,
+ receiverEmailAddress,
+ subject,
+ expectedCount
+ );
+ } finally {
+ await adminContext.close();
+ }
+}
+
/**
* Open the WP Mail Logging entry for a given recipient and subject, leaving its modal open.
*
@@ -849,14 +1192,3 @@ export async function triggerStockNotificationsBatch(
): Promise< void > {
await page.goto( '?process-waiting-actions' );
}
-
-/**
- * Generate a unique guest email address for a test so mail-log assertions don't collide.
- *
- * @param {string} prefix Short descriptor of the test.
- */
-export function uniqueGuestEmail( prefix = 'bis' ): string {
- return `${ prefix }-${ Date.now() }-${ Math.floor(
- Math.random() * 1000
- ) }@example.com`;
-}