test(e2e): convert the auth specs onto page objects (#137)
First conversion batch: auth, password-reset, resend-verification. verify-email is left alone — it already used semantic locators and duplicated nothing, and rewriting it to prove a point would be churn. Four of the nine copies of "register a customer" go here. auth.spec.ts and password-reset.spec.ts each carried their own `register`, and resend-verification.spec.ts its own `registerCustomer`, all three re-explaining the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning in slightly different words. Two also carried their own `logout`, and two their own `uniqueEmail` with different prefixes. Most of those tests were not about registering. They needed an account to exist so they could test logging out, resetting a password, or resending a verification email, and they paid for a bcrypt round-trip through the form to get one. Those now take the `customer` fixture, which registers through the API. The three tests that genuinely are about the registration form still drive it, because the thing under test has to be the thing exercised. The batch drops from 47s to 26s as a side effect, which is the cost of that round-trip made visible. password-reset.spec.ts loses its inline pg.Client. The reasoning for reading the database directly is unchanged and still right — an endpoint returning a reset token for an arbitrary address is account takeover if it is ever reachable — but it now lives in support/db.ts where it cannot be copied into the next spec wanting a shortcut. It also stops defaulting to port 55432, which is the integration suite's disposable Postgres rather than the database the app under test is connected to, and is Hyper-V-reserved on at least one machine here. Both tests in that file previously failed with a bare ECONNREFUSED unless TEST_PGPORT was set by hand; they now pass with no environment at all. New PasswordResetPages object covers both halves of recovery — requesting a link, and using one — because they are one flow and a test usually crosses between them. One lint decision worth recording. Requesting a Playwright fixture IS using it: destructuring `customer` is what makes the account exist, whether or not the body then reads the address. The linter cannot see that side effect and reports every such fixture as an unused variable. The first attempt at appeasing it was a `void customer;` line per test, which is noise standing in for a comment — and sonarjs flags that too, so it traded one warning for another. `no-unused-vars` is now configured with `args: 'none'` for tests only, with the reason written next to it. Variables are still checked; only parameters are exempt. Verified: 26/26 in the converted batch, and 127 passed in the full suite with four failures — three in the known #116 flaky family, and resend-verification's rate-limit test, which passes 9/9 across three repeats in isolation and is timing-sensitive under parallel load rather than changed by this commit. Refs #137
This commit is contained in:
@@ -133,6 +133,14 @@ export default tseslint.config(
|
|||||||
// Page objects hold locators built in the constructor and never
|
// Page objects hold locators built in the constructor and never
|
||||||
// reassigned. Flagging them as mutable props does not apply to a class.
|
// reassigned. Flagging them as mutable props does not apply to a class.
|
||||||
'sonarjs/prefer-read-only-props': 'off',
|
'sonarjs/prefer-read-only-props': 'off',
|
||||||
|
|
||||||
|
// Requesting a Playwright fixture IS using it — destructuring `customer`
|
||||||
|
// is what makes the account exist, whether or not the body then reads the
|
||||||
|
// address. The linter cannot see that side effect and reports every such
|
||||||
|
// fixture as an unused variable. Left on, the alternative is a `void
|
||||||
|
// customer;` line in each test, which is noise standing in for a comment.
|
||||||
|
// Variables are still checked; only parameters are exempt.
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+113
-117
@@ -1,56 +1,27 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect, PASSWORD, uniqueEmail } from './fixtures';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
|
|
||||||
function uniqueEmail(): string {
|
|
||||||
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registering closes the auth modal and returns the customer to the page behind
|
|
||||||
// it — the storefront, for a direct visit to /register — rather than navigating
|
|
||||||
// to /account. So the header, not the URL, is what proves the session exists.
|
|
||||||
//
|
|
||||||
// The wait is generous because this is a bcrypt round-trip rather than a render,
|
|
||||||
// and the suite's workers all register at once.
|
|
||||||
async function register(page: Page, email: string) {
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
|
||||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out lives inside the account view, which is a modal over the storefront.
|
|
||||||
async function openAccount(page: Page) {
|
|
||||||
await page.goto('/account');
|
|
||||||
await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Customer accounts', () => {
|
test.describe('Customer accounts', () => {
|
||||||
// Both names are required from anyone new so every email has a first name to
|
// Both names are required from anyone new so every email has a first name to
|
||||||
// greet with (#106). The server refuses without them; this is the form
|
// greet with (#106). The server refuses without them; this is the form
|
||||||
// refusing first, so nobody gets a round trip to find out.
|
// refusing first, so nobody gets a round trip to find out.
|
||||||
test('will not submit a registration without both names', async ({ page }) => {
|
test('will not submit a registration without both names', async ({ authModal, header }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
await authModal.fillRegistration({ email: uniqueEmail(), password: PASSWORD });
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await authModal.submitRegistration();
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('First name is required')).toBeVisible();
|
await expect(authModal.registerDialog.getByText('First name is required')).toBeVisible();
|
||||||
await expect(page.getByText('Last name is required')).toBeVisible();
|
await expect(authModal.registerDialog.getByText('Last name is required')).toBeVisible();
|
||||||
// Still on the form rather than signed in.
|
// Still on the form rather than signed in.
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0);
|
await expect(header.myAccountButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
test('marketing consent checkbox is unchecked by default', async ({ authModal }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
await expect(authModal.marketingConsent).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the consent label is the exact wording the server records', async ({ page }) => {
|
test('the consent label is the exact wording the server records', async ({ authModal }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
|
|
||||||
// The stored consent text is kept verbatim so the record says what the
|
// The stored consent text is kept verbatim so the record says what the
|
||||||
// customer actually saw. Three different wordings were in circulation
|
// customer actually saw. Three different wordings were in circulation
|
||||||
@@ -58,83 +29,95 @@ test.describe('Customer accounts', () => {
|
|||||||
// prompt, and none of them matched what was stored.
|
// prompt, and none of them matched what was stored.
|
||||||
const consent =
|
const consent =
|
||||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||||
await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent);
|
await expect(authModal.registerDialog).toContainText(consent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registering signs the customer in and returns them where they were', async ({ page }) => {
|
// Drives the form rather than taking the `customer` fixture: this test is
|
||||||
|
// about registering, so the thing under test has to be the thing exercised.
|
||||||
|
test('registering signs the customer in and returns them where they were', async ({
|
||||||
|
page,
|
||||||
|
authModal,
|
||||||
|
header,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
await register(page, email);
|
await authModal.gotoRegister();
|
||||||
|
await authModal.fillRegistration({
|
||||||
|
email,
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'Customer'
|
||||||
|
});
|
||||||
|
await authModal.submitRegistration();
|
||||||
|
await header.waitForSignedIn();
|
||||||
|
|
||||||
// Back on the storefront, signed in — not moved to the account page.
|
// Back on the storefront, signed in — not moved to the account page.
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
await openAccount(page);
|
await accountModal.open();
|
||||||
await expect(page.getByText(email)).toBeVisible();
|
await expect(accountModal.emailText(email)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects login with the wrong password', async ({ page }) => {
|
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
|
||||||
const email = uniqueEmail();
|
await accountModal.openAndLogOut();
|
||||||
await register(page, email);
|
await expect(header.logInButton).toBeVisible();
|
||||||
await openAccount(page);
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
|
|
||||||
await page.goto('/login');
|
await authModal.gotoLogIn();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.logIn(customer.email, 'wrong-password');
|
||||||
await page.getByLabel('Password').fill('wrong-password');
|
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logging out returns to the home page and resets the header', async ({ page }) => {
|
test('logging out returns to the home page and resets the header', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal,
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
header
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
|
||||||
// A server round-trip followed by re-rendering the storefront behind the
|
// A server round-trip followed by re-rendering the storefront behind the
|
||||||
// modal, so the 5s default is too tight when workers run concurrently.
|
// modal, so the 5s default is too tight when workers run concurrently.
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(header.logInButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
await expect(header.signUpButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
await expect(header.myAccountButton).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the logged-out header survives a reload', async ({ page }) => {
|
test('the logged-out header survives a reload', async ({ page, customer, accountModal, header }) => {
|
||||||
await register(page, uniqueEmail());
|
await accountModal.openAndLogOut();
|
||||||
await openAccount(page);
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
||||||
|
|
||||||
// Proves the server session was actually destroyed, rather than the header
|
// Proves the server session was actually destroyed, rather than the header
|
||||||
// merely being repainted from stale client state.
|
// merely being repainted from stale client state.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(header.logInButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
await expect(header.myAccountButton).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logging out does not leave the account page on the back stack', async ({ page }) => {
|
test('logging out does not leave the account page on the back stack', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||||
|
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).not.toHaveURL(/\/account/);
|
await expect(page).not.toHaveURL(/\/account/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
|
test('a failed logout says so instead of appearing to succeed', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
await page.route('**/api/customers/logout', (route) =>
|
await page.route('**/api/customers/logout', (route) =>
|
||||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
await accountModal.logOut();
|
||||||
|
|
||||||
// The session cookie is still valid, so pretending to be logged out would
|
// The session cookie is still valid, so pretending to be logged out would
|
||||||
// silently log the customer back in on their next reload.
|
// silently log the customer back in on their next reload.
|
||||||
@@ -144,69 +127,82 @@ test.describe('Customer accounts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Auth routes are not dead ends', () => {
|
test.describe('Auth routes are not dead ends', () => {
|
||||||
test('opening Log in from the header closes back to where browsing left off', async ({ page }) => {
|
test('opening Log in from the header closes back to where browsing left off', async ({
|
||||||
|
page,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Log in' });
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
await expect(modal).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
await modal.getByRole('button', { name: 'Close' }).click();
|
await authModal.closeButton.click();
|
||||||
|
|
||||||
await expect(modal).toBeHidden();
|
await expect(authModal.logInDialog).toBeHidden();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => {
|
test('a direct visit opens over the storefront rather than a blank page', async ({
|
||||||
await page.goto('/login');
|
authModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
await expect(header.siteTitle).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching between sign in and sign up keeps one history entry', async ({ page }) => {
|
test('switching between sign in and sign up keeps one history entry', async ({
|
||||||
|
page,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Create Account' }).click();
|
await authModal.createAccountTab.click();
|
||||||
await expect(page).toHaveURL(/\/register/);
|
await expect(page).toHaveURL(/\/register/);
|
||||||
await page.getByRole('tab', { name: 'Log In' }).click();
|
await authModal.logInTab.click();
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
// Back returns to browsing rather than walking through each tab visited.
|
// Back returns to browsing rather than walking through each tab visited.
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden();
|
await expect(authModal.logInDialog).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signing in from the header returns to the page behind, signed in', async ({ page }) => {
|
test('signing in from the header returns to the page behind, signed in', async ({
|
||||||
const email = uniqueEmail();
|
page,
|
||||||
await register(page, email);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal,
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
header,
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
authModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
const modal = page.getByRole('dialog', { name: 'Log in' });
|
await authModal.logIn(customer.email, customer.password);
|
||||||
await modal.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await modal.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await modal.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await header.waitForSignedIn();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reaching password recovery from the login form keeps a way back', async ({ page }) => {
|
test('reaching password recovery from the login form keeps a way back', async ({
|
||||||
await page.goto('/login');
|
page,
|
||||||
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
authModal,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
await authModal.forgotPasswordButton.click();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Reset your password' });
|
await expect(passwordReset.requestDialog).toBeVisible();
|
||||||
await expect(modal).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/forgot-password/);
|
await expect(page).toHaveURL(/\/forgot-password/);
|
||||||
|
|
||||||
await modal.getByRole('button', { name: 'Sign in' }).click();
|
await passwordReset.signInButton.click();
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AuthModal } from './pages/AuthModal';
|
|||||||
import { AccountModal } from './pages/AccountModal';
|
import { AccountModal } from './pages/AccountModal';
|
||||||
import { StorefrontPage } from './pages/StorefrontPage';
|
import { StorefrontPage } from './pages/StorefrontPage';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
|
import { PasswordResetPages } from './pages/PasswordResetPages';
|
||||||
import { uniqueEmail } from './support/api';
|
import { uniqueEmail } from './support/api';
|
||||||
|
|
||||||
// Re-exported so specs can import everything from here — expect, Page,
|
// Re-exported so specs can import everything from here — expect, Page,
|
||||||
@@ -38,6 +39,7 @@ interface Pages {
|
|||||||
accountModal: AccountModal;
|
accountModal: AccountModal;
|
||||||
storefront: StorefrontPage;
|
storefront: StorefrontPage;
|
||||||
admin: AdminPage;
|
admin: AdminPage;
|
||||||
|
passwordReset: PasswordResetPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -88,6 +90,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
|||||||
admin: async ({ page }, use) => {
|
admin: async ({ page }, use) => {
|
||||||
await use(new AdminPage(page));
|
await use(new AdminPage(page));
|
||||||
},
|
},
|
||||||
|
passwordReset: async ({ page }, use) => {
|
||||||
|
await use(new PasswordResetPages(page));
|
||||||
|
},
|
||||||
|
|
||||||
adminApi: async ({ playwright, baseURL }, use) => {
|
adminApi: async ({ playwright, baseURL }, use) => {
|
||||||
const context = await playwright.request.newContext({ baseURL });
|
const context = await playwright.request.newContext({ baseURL });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class AccountModal {
|
|||||||
readonly logOutButton: Locator;
|
readonly logOutButton: Locator;
|
||||||
readonly orderHistoryButton: Locator;
|
readonly orderHistoryButton: Locator;
|
||||||
readonly closeButton: Locator;
|
readonly closeButton: Locator;
|
||||||
|
readonly resendVerificationButton: Locator;
|
||||||
|
|
||||||
readonly firstName: Locator;
|
readonly firstName: Locator;
|
||||||
readonly lastName: Locator;
|
readonly lastName: Locator;
|
||||||
@@ -30,6 +31,7 @@ export class AccountModal {
|
|||||||
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
|
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
|
||||||
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
|
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
|
||||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||||
|
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
|
||||||
|
|
||||||
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||||
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
||||||
@@ -59,6 +61,20 @@ export class AccountModal {
|
|||||||
await this.logOutButton.click();
|
await this.logOutButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the account view and logs out, which is the only route to signing out
|
||||||
|
* — there is no header control for it.
|
||||||
|
*
|
||||||
|
* Does not wait for the result. Several tests assert different things about
|
||||||
|
* what logging out does: the URL it lands on, the header it leaves behind, a
|
||||||
|
* failure it reports. Waiting here would make one of those the action's
|
||||||
|
* contract and quietly weaken the others.
|
||||||
|
*/
|
||||||
|
async openAndLogOut(): Promise<void> {
|
||||||
|
await this.open();
|
||||||
|
await this.logOut();
|
||||||
|
}
|
||||||
|
|
||||||
/** The address the account view shows, which is how a test knows whose it is. */
|
/** The address the account view shows, which is how a test knows whose it is. */
|
||||||
emailText(email: string): Locator {
|
emailText(email: string): Locator {
|
||||||
return this.dialog.getByText(email);
|
return this.dialog.getByText(email);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class AuthModal {
|
|||||||
readonly password: Locator;
|
readonly password: Locator;
|
||||||
readonly marketingConsent: Locator;
|
readonly marketingConsent: Locator;
|
||||||
readonly createAccountButton: Locator;
|
readonly createAccountButton: Locator;
|
||||||
|
readonly createAccountTab: Locator;
|
||||||
|
readonly logInTab: Locator;
|
||||||
|
readonly forgotPasswordButton: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
||||||
@@ -33,6 +36,9 @@ export class AuthModal {
|
|||||||
this.password = page.getByLabel('Password');
|
this.password = page.getByLabel('Password');
|
||||||
this.marketingConsent = page.getByRole('checkbox');
|
this.marketingConsent = page.getByRole('checkbox');
|
||||||
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
||||||
|
this.createAccountTab = page.getByRole('tab', { name: 'Create Account' });
|
||||||
|
this.logInTab = page.getByRole('tab', { name: 'Log In' });
|
||||||
|
this.forgotPasswordButton = page.getByRole('button', { name: 'Forgot password?' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The submit button inside the log in dialog, not the one that opened it. */
|
/** The submit button inside the log in dialog, not the one that opened it. */
|
||||||
@@ -40,6 +46,24 @@ export class AuthModal {
|
|||||||
return this.logInDialog.getByRole('button', { name: 'Log in' });
|
return this.logInDialog.getByRole('button', { name: 'Log in' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes whichever of the two dialogs is open. */
|
||||||
|
get closeButton(): Locator {
|
||||||
|
return this.page.getByRole('dialog').getByRole('button', { name: 'Close' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs in through the modal, wherever it was opened from.
|
||||||
|
*
|
||||||
|
* Scoped to the dialog throughout: the storefront behind it has its own
|
||||||
|
* "Log in" button — the one that opened this — and an unscoped fill or click
|
||||||
|
* matches both.
|
||||||
|
*/
|
||||||
|
async logIn(email: string, password: string): Promise<void> {
|
||||||
|
await this.logInDialog.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await this.logInDialog.getByLabel('Password').fill(password);
|
||||||
|
await this.submitLogInButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
async gotoRegister(): Promise<void> {
|
async gotoRegister(): Promise<void> {
|
||||||
await this.page.goto('/register');
|
await this.page.goto('/register');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export class Header {
|
|||||||
readonly siteTitle: Locator;
|
readonly siteTitle: Locator;
|
||||||
readonly myAccountButton: Locator;
|
readonly myAccountButton: Locator;
|
||||||
readonly logInButton: Locator;
|
readonly logInButton: Locator;
|
||||||
|
readonly signUpButton: Locator;
|
||||||
readonly cartButton: Locator;
|
readonly cartButton: Locator;
|
||||||
readonly themeToggle: Locator;
|
readonly themeToggle: Locator;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export class Header {
|
|||||||
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
||||||
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
|
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
|
||||||
this.logInButton = page.getByRole('button', { name: 'Log in' });
|
this.logInButton = page.getByRole('button', { name: 'Log in' });
|
||||||
|
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
|
||||||
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
||||||
this.themeToggle = page.getByRole('switch');
|
this.themeToggle = page.getByRole('switch');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Locator, Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two halves of password recovery: asking for a link, and using one.
|
||||||
|
*
|
||||||
|
* They are one object because they are one flow and a test usually crosses
|
||||||
|
* between them. Requesting is a modal reached from the login form; setting the
|
||||||
|
* new password is a route the customer arrives at from their email, with no
|
||||||
|
* page behind it to return to.
|
||||||
|
*/
|
||||||
|
export class PasswordResetPages {
|
||||||
|
readonly requestDialog: Locator;
|
||||||
|
readonly email: Locator;
|
||||||
|
readonly sendResetLinkButton: Locator;
|
||||||
|
readonly signInButton: Locator;
|
||||||
|
|
||||||
|
readonly newPassword: Locator;
|
||||||
|
readonly confirmNewPassword: Locator;
|
||||||
|
readonly setNewPasswordButton: Locator;
|
||||||
|
|
||||||
|
constructor(private readonly page: Page) {
|
||||||
|
this.requestDialog = page.getByRole('dialog', { name: 'Reset your password' });
|
||||||
|
this.email = page.getByRole('textbox', { name: 'Email' });
|
||||||
|
this.sendResetLinkButton = page.getByRole('button', { name: 'Send reset link' });
|
||||||
|
this.signInButton = this.requestDialog.getByRole('button', { name: 'Sign in' });
|
||||||
|
|
||||||
|
this.newPassword = page.getByLabel('New password', { exact: true });
|
||||||
|
this.confirmNewPassword = page.getByLabel('Confirm new password');
|
||||||
|
this.setNewPasswordButton = page.getByRole('button', { name: 'Set new password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async gotoRequest(): Promise<void> {
|
||||||
|
await this.page.goto('/forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the reset form, with or without a token.
|
||||||
|
*
|
||||||
|
* Omitting it is a real case rather than a degenerate one: a link that lost
|
||||||
|
* its token has to explain itself instead of failing on submit.
|
||||||
|
*/
|
||||||
|
async gotoReset(token?: string): Promise<void> {
|
||||||
|
await this.page.goto(token ? `/reset-password?token=${token}` : '/reset-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNewPassword(password: string, confirmation = password): Promise<void> {
|
||||||
|
await this.newPassword.fill(password);
|
||||||
|
await this.confirmNewPassword.fill(confirmation);
|
||||||
|
await this.setNewPasswordButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestLinkFor(email: string): Promise<void> {
|
||||||
|
await this.email.fill(email);
|
||||||
|
await this.sendResetLinkButton.click();
|
||||||
|
await expect(this.page.getByText('Check your email')).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +1,121 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
import { Client } from 'pg';
|
import { readPasswordResetToken } from './support/db';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
const NEW_PASSWORD = 'a-brand-new-password';
|
const NEW_PASSWORD = 'a-brand-new-password';
|
||||||
|
|
||||||
const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
||||||
|
|
||||||
async function register(page: Page, email: string) {
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
|
||||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
|
||||||
// the header rather than the URL is what proves the session exists. The wait
|
|
||||||
// is generous because this is a bcrypt round-trip rather than a render.
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out lives inside the account view, which is a modal over the storefront.
|
|
||||||
async function logout(page: Page) {
|
|
||||||
await page.goto('/account');
|
|
||||||
await page.getByRole('dialog', { name: 'My Account' })
|
|
||||||
.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
// A server round-trip followed by re-rendering the storefront behind the
|
|
||||||
// modal, so the 5s default is too tight when workers run concurrently.
|
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Password reset', () => {
|
test.describe('Password reset', () => {
|
||||||
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
|
test('the login page offers a way to recover a forgotten password', async ({
|
||||||
await page.goto('/login');
|
page,
|
||||||
// Recovery is now reached from the login modal rather than a link on a
|
authModal,
|
||||||
// page, and its title is the modal's rather than a heading.
|
passwordReset
|
||||||
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
// Recovery is reached from the login modal rather than a link on a page,
|
||||||
|
// and its title is the modal's rather than a heading.
|
||||||
|
await authModal.forgotPasswordButton.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/forgot-password/);
|
await expect(page).toHaveURL(/\/forgot-password/);
|
||||||
await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible();
|
await expect(passwordReset.requestDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => {
|
test('requesting a reset confirms without revealing whether the account exists', async ({
|
||||||
await page.goto('/forgot-password');
|
page,
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com');
|
passwordReset
|
||||||
await page.getByRole('button', { name: 'Send reset link' }).click();
|
}) => {
|
||||||
|
await passwordReset.gotoRequest();
|
||||||
|
await passwordReset.requestLinkFor('definitely-nobody@example.com');
|
||||||
|
|
||||||
// Identical wording either way; a differing message would make this an
|
// Identical wording either way; a differing message would make this an
|
||||||
// account-enumeration oracle.
|
// account-enumeration oracle.
|
||||||
await expect(page.getByText('Check your email')).toBeVisible();
|
|
||||||
await expect(page.getByText(/If an account exists/)).toBeVisible();
|
await expect(page.getByText(/If an account exists/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => {
|
test('a reset link with no token explains itself instead of failing on submit', async ({
|
||||||
await page.goto('/reset-password');
|
page,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await passwordReset.gotoReset();
|
||||||
|
|
||||||
await expect(page.getByText('This link is incomplete')).toBeVisible();
|
await expect(page.getByText('This link is incomplete')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0);
|
await expect(passwordReset.setNewPasswordButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects a mismatched confirmation before contacting the server', async ({ page }) => {
|
test('rejects a mismatched confirmation before contacting the server', async ({
|
||||||
await page.goto('/reset-password?token=whatever');
|
page,
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
passwordReset
|
||||||
await page.getByLabel('Confirm new password').fill('something-else-entirely');
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
await passwordReset.gotoReset('whatever');
|
||||||
|
await passwordReset.setNewPassword(NEW_PASSWORD, 'something-else-entirely');
|
||||||
|
|
||||||
await expect(page.getByText('The passwords do not match')).toBeVisible();
|
await expect(page.getByText('The passwords do not match')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports an invalid token rather than appearing to succeed', async ({ page }) => {
|
test('reports an invalid token rather than appearing to succeed', async ({
|
||||||
await page.goto('/reset-password?token=not-a-real-token');
|
page,
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
passwordReset
|
||||||
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
await passwordReset.gotoReset('not-a-real-token');
|
||||||
|
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||||
|
|
||||||
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/reset-password/);
|
await expect(page).toHaveURL(/\/reset-password/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a customer can reset their password and sign in with the new one', async ({ page, request }) => {
|
test('a customer can reset their password and sign in with the new one', async ({
|
||||||
const email = uniqueEmail();
|
request,
|
||||||
await register(page, email);
|
customer,
|
||||||
await logout(page);
|
accountModal,
|
||||||
|
header,
|
||||||
|
authModal,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
// The reset link arrives by email, which the tests can't read. Request the
|
// The reset link arrives by email, which the tests can't read. Request the
|
||||||
// reset through the real endpoint, then read the issued token the way the
|
// reset through the real endpoint, then read the issued token the way the
|
||||||
// customer's mail client would deliver it.
|
// customer's mail client would deliver it.
|
||||||
const requested = await request.post('/api/customers/request-password-reset', { data: { email } });
|
const requested = await request.post('/api/customers/request-password-reset', {
|
||||||
|
data: { email: customer.email }
|
||||||
|
});
|
||||||
expect(requested.ok()).toBeTruthy();
|
expect(requested.ok()).toBeTruthy();
|
||||||
|
|
||||||
const token = await readResetToken(email);
|
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
|
||||||
await page.goto(`/reset-password?token=${token}`);
|
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
||||||
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
|
||||||
|
|
||||||
// The reset signs them in and closes back to the storefront — the link came
|
// The reset signs them in and closes back to the storefront — the link came
|
||||||
// from an email, so there is no page behind it to return to.
|
// from an email, so there is no page behind it to return to.
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await header.waitForSignedIn();
|
||||||
await page.goto('/account');
|
await accountModal.open();
|
||||||
await expect(page.getByText(email)).toBeVisible();
|
await expect(accountModal.emailText(customer.email)).toBeVisible();
|
||||||
|
|
||||||
// And the new password actually works on a fresh sign-in.
|
// And the new password actually works on a fresh sign-in.
|
||||||
await logout(page);
|
await accountModal.logOut();
|
||||||
await page.goto('/login');
|
await expect(header.logInButton).toBeVisible();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.gotoLogIn();
|
||||||
await page.getByLabel('Password').fill(NEW_PASSWORD);
|
await authModal.logIn(customer.email, NEW_PASSWORD);
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
await header.waitForSignedIn();
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the old password stops working after a reset', async ({ page, request }) => {
|
test('the old password stops working after a reset', async ({
|
||||||
const email = uniqueEmail();
|
page,
|
||||||
await register(page, email);
|
request,
|
||||||
await logout(page);
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await request.post('/api/customers/request-password-reset', { data: { email } });
|
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
|
||||||
const token = await readResetToken(email);
|
const token = await readPasswordResetToken(customer.email);
|
||||||
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
|
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
|
||||||
|
|
||||||
await page.goto('/login');
|
await authModal.gotoLogIn();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.logIn(customer.email, customer.password);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
|
await expect(header.myAccountButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The token is only ever delivered by email, which these tests cannot read.
|
|
||||||
//
|
|
||||||
// It is read straight from the database rather than through a helper endpoint:
|
|
||||||
// an endpoint that returns a password-reset token for an arbitrary address is
|
|
||||||
// account takeover for every customer if it is ever reachable, and an
|
|
||||||
// environment gate is a thin thing to stand between that and production. Doing
|
|
||||||
// it here keeps the capability entirely inside the test process.
|
|
||||||
async function readResetToken(email: string): Promise<string> {
|
|
||||||
const client = new Client({
|
|
||||||
host: process.env.TEST_PGHOST || 'localhost',
|
|
||||||
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
|
||||||
user: process.env.TEST_PGUSER || 'redefined_test',
|
|
||||||
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
|
||||||
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
const { rows } = await client.query(
|
|
||||||
`SELECT t.token FROM customer_tokens t
|
|
||||||
JOIN customers c ON c.id = t.customer_id
|
|
||||||
WHERE c.email = $1 AND t.kind = 'password_reset'
|
|
||||||
ORDER BY t.created_at DESC LIMIT 1`,
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
|
|
||||||
return rows[0].token as string;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,20 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
|
|
||||||
const uniqueEmail = () => `resend-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
||||||
|
|
||||||
// The generous wait matches the other account specs: registration is a bcrypt
|
|
||||||
// round-trip rather than a render, and runs past Playwright's 5s default when
|
|
||||||
// the suite's workers all register at once.
|
|
||||||
async function registerCustomer(page: Page): Promise<string> {
|
|
||||||
const email = uniqueEmail();
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
|
||||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
|
|
||||||
|
|
||||||
test.describe('Resending your verification email', () => {
|
test.describe('Resending your verification email', () => {
|
||||||
test('the account page offers it while the address is unverified', async ({ page }) => {
|
test('the account page offers it while the address is unverified', async ({
|
||||||
await registerCustomer(page);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
const modal = accountModal(page);
|
await expect(accountModal.dialog.getByText('Email not verified')).toBeVisible();
|
||||||
await expect(modal.getByText('Email not verified')).toBeVisible();
|
await expect(accountModal.resendVerificationButton).toBeVisible();
|
||||||
await expect(modal.getByRole('button', { name: 'Send it again' })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirms when it has sent', async ({ page }) => {
|
test('confirms when it has sent', async ({ page, customer, accountModal }) => {
|
||||||
await registerCustomer(page);
|
await accountModal.open();
|
||||||
await page.goto('/account');
|
|
||||||
|
|
||||||
await accountModal(page).getByRole('button', { name: 'Send it again' }).click();
|
await accountModal.resendVerificationButton.click();
|
||||||
|
|
||||||
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
|
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -43,11 +22,14 @@ test.describe('Resending your verification email', () => {
|
|||||||
// The message the customer gets on the fourth attempt is the point of the
|
// The message the customer gets on the fourth attempt is the point of the
|
||||||
// limiter's copy: it says the mail probably did send and where to look,
|
// limiter's copy: it says the mail probably did send and where to look,
|
||||||
// rather than only that a limit exists.
|
// rather than only that a limit exists.
|
||||||
test('says something useful once the allowance runs out', async ({ page }) => {
|
test('says something useful once the allowance runs out', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
await page.goto('/account');
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
const resend = accountModal(page).getByRole('button', { name: 'Send it again' });
|
const resend = accountModal.resendVerificationButton;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await resend.click();
|
await resend.click();
|
||||||
await expect(resend).toBeEnabled();
|
await expect(resend).toBeEnabled();
|
||||||
|
|||||||
Reference in New Issue
Block a user