Merge pull request 'test(e2e): add page objects, fixtures and a typed test build (#137)' (#149) from feature/137-playwright-page-objects into main
Reviewed-on: #149
This commit was merged in pull request #149.
This commit is contained in:
@@ -50,9 +50,6 @@ export default tseslint.config(
|
||||
{ plugins: { 'jsx-a11y': jsxA11y } },
|
||||
|
||||
{
|
||||
// `tests/` and the Playwright specs are deliberately out of scope for now:
|
||||
// tsconfig.json only includes `src`, so type-aware linting has no program
|
||||
// for them, and widening it is a separate change with its own count.
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
@@ -83,5 +80,59 @@ export default tseslint.config(
|
||||
// the fix is one attribute.
|
||||
'jsx-a11y/alt-text': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// The Playwright suite. Out of scope until #137, because tsconfig.json
|
||||
// includes only `src` and type-aware linting had no program for these
|
||||
// files. tsconfig.test.json is that program.
|
||||
//
|
||||
// `project` rather than `projectService`: the service resolves a file to the
|
||||
// nearest tsconfig.json, which for tests/ is the one that excludes them, and
|
||||
// every file then errors as not part of a project.
|
||||
files: ['tests/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.test.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// The one that matters most here. Playwright's API is almost entirely
|
||||
// promises, and a missing `await` on an assertion does not fail — it
|
||||
// passes, having asserted nothing, which is the worst outcome a test can
|
||||
// have. The project's own Playwright notes already warn about it; this
|
||||
// enforces it.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
|
||||
// Switched off for tests rather than left as warnings. #60's whole
|
||||
// argument is that a gate nobody reads is not a gate, and bringing these
|
||||
// files in scope added 45 warnings of which none were defects. A rule
|
||||
// that cannot be true here is noise that hides the rules that can.
|
||||
//
|
||||
// There is no React in this directory. The hooks rules fire on ordinary
|
||||
// functions whose parameter happens to be named `use` — which Playwright
|
||||
// fixtures are, by its own API.
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
|
||||
// Test credentials are the point of a test, and the project's own rule is
|
||||
// that they must live only in test paths — which is here. Flagging them
|
||||
// where they belong trains the reader to ignore the rule where they do
|
||||
// not.
|
||||
'sonarjs/no-hardcoded-passwords': 'off',
|
||||
|
||||
// Math.random builds unique fixture names so parallel workers do not
|
||||
// collide. Nothing here is a secret, and a cryptographic generator would
|
||||
// say something untrue about what the value is for.
|
||||
'sonarjs/pseudo-random': 'off',
|
||||
|
||||
// Page objects hold locators built in the constructor and never
|
||||
// reassigned. Flagging them as mutable props does not apply to a class.
|
||||
'sonarjs/prefer-read-only-props': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src",
|
||||
"build": "tsc && tsc -p tsconfig.test.json --noEmit && vite build",
|
||||
"lint": "eslint src tests",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:cov": "cross-env COVERAGE=true playwright test",
|
||||
"coverage:report": "node scripts/coverage-report.js"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
import { test as base, APIRequestContext, expect } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
import { Header } from './pages/Header';
|
||||
import { AuthModal } from './pages/AuthModal';
|
||||
import { AccountModal } from './pages/AccountModal';
|
||||
import { StorefrontPage } from './pages/StorefrontPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { uniqueEmail } from './support/api';
|
||||
|
||||
// Re-exported so specs can import everything from here — expect, Page,
|
||||
// APIRequestContext — and get the coverage-collecting `test` at the same time.
|
||||
// The explicit `test` below wins over the star export.
|
||||
export * from '@playwright/test';
|
||||
export * from './support/api';
|
||||
|
||||
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
|
||||
const collectingCoverage = process.env.COVERAGE === 'true';
|
||||
@@ -15,6 +22,49 @@ const collectingCoverage = process.env.COVERAGE === 'true';
|
||||
// an empty run fails loudly instead of publishing 0% — see the note below.
|
||||
const MARKER = path.join(NYC_OUTPUT, '.collected');
|
||||
|
||||
/** The password every seeded customer gets. Long enough to pass the 8-char rule. */
|
||||
export const PASSWORD = 'supersecret123';
|
||||
|
||||
export interface RegisteredCustomer {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface Pages {
|
||||
header: Header;
|
||||
authModal: AuthModal;
|
||||
accountModal: AccountModal;
|
||||
storefront: StorefrontPage;
|
||||
admin: AdminPage;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
/**
|
||||
* A request context for the admin API, on the configured baseURL.
|
||||
*
|
||||
* Specs used to build their own with `playwright.request.newContext(...)`, one
|
||||
* of them against a hardcoded `http://localhost:5173`, which meant changing
|
||||
* the port in the config moved every test except that one.
|
||||
*/
|
||||
adminApi: APIRequestContext;
|
||||
/**
|
||||
* A freshly registered customer, already signed in on `page`.
|
||||
*
|
||||
* Registering through the API rather than the form, deliberately. Nine specs
|
||||
* drove the registration form purely to arrive at a signed-in session, so a
|
||||
* broken form failed a hundred tests that were not about it — and each paid
|
||||
* for a bcrypt round-trip through the UI. The specs that are about
|
||||
* registration use `authModal` and drive it properly.
|
||||
*
|
||||
* `page.request` shares the browser context's cookie jar, so the session
|
||||
* cookie the endpoint sets belongs to `page`. The caller still has to
|
||||
* navigate: the session exists, but a page loaded before it does not know.
|
||||
*/
|
||||
customer: RegisteredCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes istanbul's per-page counters after each test.
|
||||
*
|
||||
@@ -22,7 +72,54 @@ const MARKER = path.join(NYC_OUTPUT, '.collected');
|
||||
* has to be read before the page closes — one file per test, because the suite
|
||||
* runs fullyParallel and workers would otherwise overwrite each other.
|
||||
*/
|
||||
export const test = base.extend<{ collectCoverage: void }>({
|
||||
export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
||||
header: async ({ page }, use) => {
|
||||
await use(new Header(page));
|
||||
},
|
||||
authModal: async ({ page }, use) => {
|
||||
await use(new AuthModal(page));
|
||||
},
|
||||
accountModal: async ({ page }, use) => {
|
||||
await use(new AccountModal(page));
|
||||
},
|
||||
storefront: async ({ page }, use) => {
|
||||
await use(new StorefrontPage(page));
|
||||
},
|
||||
admin: async ({ page }, use) => {
|
||||
await use(new AdminPage(page));
|
||||
},
|
||||
|
||||
adminApi: async ({ playwright, baseURL }, use) => {
|
||||
const context = await playwright.request.newContext({ baseURL });
|
||||
await use(context);
|
||||
await context.dispose();
|
||||
},
|
||||
|
||||
customer: async ({ page }, use) => {
|
||||
const details: RegisteredCustomer = {
|
||||
email: uniqueEmail(),
|
||||
password: PASSWORD,
|
||||
firstName: 'Test',
|
||||
lastName: 'Customer'
|
||||
};
|
||||
|
||||
const res = await page.request.post('/api/customers/register', {
|
||||
data: {
|
||||
email: details.email,
|
||||
password: details.password,
|
||||
firstName: details.firstName,
|
||||
lastName: details.lastName,
|
||||
marketingConsent: false
|
||||
}
|
||||
});
|
||||
// Loudly, and naming the address: a failure here is a fixture failing to
|
||||
// build, and every assertion after it would fail for a reason that has
|
||||
// nothing to do with what the test is about.
|
||||
expect(res.ok(), `registering ${details.email}`).toBeTruthy();
|
||||
|
||||
await use(details);
|
||||
},
|
||||
|
||||
collectCoverage: [
|
||||
async ({ page }, use) => {
|
||||
await use();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The customer's own account view, which is a modal over the storefront rather
|
||||
* than a page of its own — /account renders the storefront with this on top.
|
||||
*
|
||||
* Fields are scoped to the dialog. Several of them ("Password", "First name")
|
||||
* share a label with the auth modal, and the two can both be in the DOM while
|
||||
* one is closing.
|
||||
*/
|
||||
export class AccountModal {
|
||||
readonly dialog: Locator;
|
||||
readonly logOutButton: Locator;
|
||||
readonly orderHistoryButton: Locator;
|
||||
readonly closeButton: Locator;
|
||||
|
||||
readonly firstName: Locator;
|
||||
readonly lastName: Locator;
|
||||
readonly saveNameButton: Locator;
|
||||
|
||||
readonly currentPassword: Locator;
|
||||
readonly newPassword: Locator;
|
||||
readonly confirmNewPassword: Locator;
|
||||
|
||||
readonly newEmail: Locator;
|
||||
readonly passwordForEmailChange: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.dialog = page.getByRole('dialog', { name: 'My Account' });
|
||||
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
|
||||
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
|
||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||
|
||||
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
||||
this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' });
|
||||
|
||||
this.currentPassword = this.dialog.getByLabel('Current password', { exact: true });
|
||||
this.newPassword = this.dialog.getByLabel('New password', { exact: true });
|
||||
this.confirmNewPassword = this.dialog.getByLabel('Confirm new password', { exact: true });
|
||||
|
||||
this.newEmail = this.dialog.getByLabel('New email address', { exact: true });
|
||||
this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the account view and waits for it.
|
||||
*
|
||||
* The wait is the action's contract rather than an assertion: everything a
|
||||
* caller does next is scoped to this dialog, and a locator resolved before it
|
||||
* exists finds nothing.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
await this.page.goto('/account');
|
||||
await expect(this.dialog).toBeVisible();
|
||||
}
|
||||
|
||||
async logOut(): Promise<void> {
|
||||
await this.logOutButton.click();
|
||||
}
|
||||
|
||||
/** The address the account view shows, which is how a test knows whose it is. */
|
||||
emailText(email: string): Locator {
|
||||
return this.dialog.getByText(email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/** The admin tabs, in the order the shell renders them. */
|
||||
export type AdminTab =
|
||||
| 'Inventory'
|
||||
| 'Categories'
|
||||
| 'Tags'
|
||||
| 'Customers'
|
||||
| 'Emails'
|
||||
| 'Settings';
|
||||
|
||||
/**
|
||||
* The admin shell: the tab strip and the panel it swaps.
|
||||
*
|
||||
* Only the active tab's panel is mounted, which is what keeps the locators
|
||||
* inside each panel unambiguous — the email editors used to be stacked and a
|
||||
* locator for "Save" matched all six.
|
||||
*
|
||||
* `activePanel` exists because several specs reached for
|
||||
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
|
||||
* visible panel. That knowledge belongs here rather than in five spec files.
|
||||
*/
|
||||
export class AdminPage {
|
||||
readonly tabList: Locator;
|
||||
readonly activePanel: Locator;
|
||||
readonly activeTabLabel: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.tabList = page.getByRole('tablist');
|
||||
this.activePanel = page.locator('.ant-tabs-tabpane-active').first();
|
||||
this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/admin');
|
||||
}
|
||||
|
||||
tab(name: AdminTab | RegExp): Locator {
|
||||
return this.page.getByRole('tab', { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a tab from wherever the browser is, navigating to /admin first.
|
||||
*
|
||||
* Takes the navigation rather than assuming it, because every caller in the
|
||||
* suite did both and half of them wrote the goto themselves.
|
||||
*/
|
||||
async open(name: AdminTab): Promise<void> {
|
||||
await this.goto();
|
||||
await this.openTab(name);
|
||||
}
|
||||
|
||||
/** Switches tabs without renavigating, for a test that visits two of them. */
|
||||
async openTab(name: AdminTab): Promise<void> {
|
||||
await this.tab(name).click();
|
||||
// The panel being mounted is the completion of the click. Without this a
|
||||
// caller's first locator resolves against the outgoing panel.
|
||||
await expect(this.activePanel).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The register / log in form.
|
||||
*
|
||||
* It is a modal wherever it appears — over the storefront at /register and
|
||||
* /login, and over whatever the customer was doing when they clicked Add to
|
||||
* Cart while signed out. One object covers all three, because it is one
|
||||
* component (`AuthForm`, shared by `AuthRouteModal` and `AuthPromptModal`).
|
||||
*
|
||||
* Locators are scoped to the dialog rather than the page. The storefront behind
|
||||
* the modal has a "Log in" button of its own — the one that opened this — so an
|
||||
* unscoped `getByRole('button', { name: 'Log in' })` matches two things and
|
||||
* fails on strict mode, which is a confusing way to learn that the modal is a
|
||||
* modal.
|
||||
*/
|
||||
export class AuthModal {
|
||||
readonly registerDialog: Locator;
|
||||
readonly logInDialog: Locator;
|
||||
readonly email: Locator;
|
||||
readonly firstName: Locator;
|
||||
readonly lastName: Locator;
|
||||
readonly password: Locator;
|
||||
readonly marketingConsent: Locator;
|
||||
readonly createAccountButton: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
||||
this.logInDialog = page.getByRole('dialog', { name: 'Log in' });
|
||||
this.email = page.getByRole('textbox', { name: 'Email' });
|
||||
this.firstName = page.getByRole('textbox', { name: 'First name' });
|
||||
this.lastName = page.getByRole('textbox', { name: 'Last name' });
|
||||
this.password = page.getByLabel('Password');
|
||||
this.marketingConsent = page.getByRole('checkbox');
|
||||
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
||||
}
|
||||
|
||||
/** The submit button inside the log in dialog, not the one that opened it. */
|
||||
get submitLogInButton(): Locator {
|
||||
return this.logInDialog.getByRole('button', { name: 'Log in' });
|
||||
}
|
||||
|
||||
async gotoRegister(): Promise<void> {
|
||||
await this.page.goto('/register');
|
||||
}
|
||||
|
||||
async gotoLogIn(): Promise<void> {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
/** Fills the registration form without submitting, for tests about validation. */
|
||||
async fillRegistration(details: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}): Promise<void> {
|
||||
await this.email.fill(details.email);
|
||||
if (details.firstName !== undefined) await this.firstName.fill(details.firstName);
|
||||
if (details.lastName !== undefined) await this.lastName.fill(details.lastName);
|
||||
await this.password.fill(details.password);
|
||||
}
|
||||
|
||||
async submitRegistration(): Promise<void> {
|
||||
await this.createAccountButton.click();
|
||||
}
|
||||
|
||||
async fillCredentials(email: string, password: string): Promise<void> {
|
||||
await this.email.fill(email);
|
||||
await this.password.fill(password);
|
||||
}
|
||||
|
||||
async submitLogIn(): Promise<void> {
|
||||
await this.submitLogInButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the modal to close, which is what registering or logging in does.
|
||||
*
|
||||
* The dialog going away is the completion of the action rather than an
|
||||
* assertion about it: a test that carries on while the modal is still over
|
||||
* the page clicks the modal's backdrop instead of what it meant to.
|
||||
*/
|
||||
async waitForDismissal(): Promise<void> {
|
||||
await expect(this.registerDialog).toHaveCount(0);
|
||||
await expect(this.logInDialog).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The site header, which is where a test finds out whether anyone is signed in.
|
||||
*
|
||||
* Registering and logging in both close their modal and return the customer to
|
||||
* the page behind it rather than navigating to /account, so the URL says
|
||||
* nothing about whether a session exists. The account button in the header is
|
||||
* what does.
|
||||
*/
|
||||
export class Header {
|
||||
readonly siteTitle: Locator;
|
||||
readonly myAccountButton: Locator;
|
||||
readonly logInButton: Locator;
|
||||
readonly cartButton: Locator;
|
||||
readonly themeToggle: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
||||
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
|
||||
this.logInButton = page.getByRole('button', { name: 'Log in' });
|
||||
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
||||
this.themeToggle = page.getByRole('switch');
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the header shows a signed-in customer.
|
||||
*
|
||||
* The timeout is generous because this waits on a bcrypt round-trip rather
|
||||
* than a render, and every worker in a parallel run registers at once. The
|
||||
* default five seconds is comfortably beaten on an idle machine and missed on
|
||||
* a loaded one, which is the recipe for a test that fails only in CI.
|
||||
*/
|
||||
async waitForSignedIn(): Promise<void> {
|
||||
await expect(this.myAccountButton).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async waitForSignedOut(): Promise<void> {
|
||||
await expect(this.myAccountButton).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
import { Header } from './Header';
|
||||
|
||||
/**
|
||||
* The public catalogue.
|
||||
*
|
||||
* The item card locator is the one place in the suite that knows the storefront
|
||||
* renders items into `.item-card` — three specs reached for that class directly,
|
||||
* and two others used `.ant-col`, which is antd's grid rather than anything this
|
||||
* application owns. Both break on an antd upgrade, in files that have nothing to
|
||||
* do with the upgrade.
|
||||
*/
|
||||
export class StorefrontPage {
|
||||
readonly header: Header;
|
||||
readonly filtersButton: Locator;
|
||||
readonly retryButton: Locator;
|
||||
readonly loadFailureHeading: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.header = new Header(page);
|
||||
this.filtersButton = page.getByRole('button', { name: /Filters/ });
|
||||
this.retryButton = page.getByRole('button', { name: 'Retry' });
|
||||
this.loadFailureHeading = page.getByRole('heading', { name: "The item list didn't load" });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the storefront and waits for the session to settle.
|
||||
*
|
||||
* Navigating remounts the app, so the session is briefly still resolving. The
|
||||
* favorite control deliberately ignores clicks in that window rather than
|
||||
* wrongly prompting a signed-in customer to sign in, so a test that clicks
|
||||
* immediately gets nothing and no error. Waiting for the header is what a real
|
||||
* customer sees settle too.
|
||||
*/
|
||||
async gotoSignedIn(): Promise<void> {
|
||||
await this.goto();
|
||||
await this.header.waitForSignedIn();
|
||||
}
|
||||
|
||||
/** One item's card, located by the name shown on it. */
|
||||
card(name: string): Locator {
|
||||
return this.page.locator('.item-card').filter({ hasText: name });
|
||||
}
|
||||
|
||||
addToCartButton(name: string): Locator {
|
||||
return this.card(name).getByRole('button', { name: 'Add to Cart' });
|
||||
}
|
||||
|
||||
/** The heart. Named for what it does rather than what it looks like. */
|
||||
favoriteToggle(name: string): Locator {
|
||||
return this.card(name).getByRole('button', { name: /favorite/i });
|
||||
}
|
||||
|
||||
async openFilters(): Promise<void> {
|
||||
await this.filtersButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the catalogue has rendered something.
|
||||
*
|
||||
* A test that asserts an item is absent needs to know the list arrived and did
|
||||
* not contain it, rather than that it asserted before the fetch resolved —
|
||||
* which passes for the wrong reason and keeps passing when the filter breaks.
|
||||
*/
|
||||
async waitForAnyItem(): Promise<void> {
|
||||
await expect(this.page.locator('.item-card').first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { APIRequestContext, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Seeding through the admin API.
|
||||
*
|
||||
* Every spec that needs stock used to write its own version of this, and the
|
||||
* copies had drifted: some checked `res.ok()`, some checked `res.status()`,
|
||||
* some checked nothing, and one built its own request context against a
|
||||
* hardcoded `http://localhost:5173` rather than the configured baseURL.
|
||||
*
|
||||
* Seeding through the API rather than the UI on purpose. Creating an item by
|
||||
* driving the admin form makes every storefront test depend on the admin form
|
||||
* working, so a broken form fails a hundred tests that are not about it.
|
||||
*/
|
||||
|
||||
export interface SeededItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Unique enough to survive a parallel run and a database nobody truncated. */
|
||||
export function uniqueSuffix(): string {
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function uniqueEmail(prefix = 'pw'): string {
|
||||
return `${prefix}-${uniqueSuffix()}@example.com`;
|
||||
}
|
||||
|
||||
interface CreateItemOptions {
|
||||
name: string;
|
||||
price?: string;
|
||||
description?: string;
|
||||
categoryId?: number | null;
|
||||
tags?: string[];
|
||||
/**
|
||||
* New items are created pending, and the storefront lists only published
|
||||
* ones. Most fixtures stand in for ordinary stock rather than staged drafts,
|
||||
* so publishing is the default — a spec that wants a draft asks for one.
|
||||
*/
|
||||
publish?: boolean;
|
||||
}
|
||||
|
||||
export async function createItem(
|
||||
api: APIRequestContext,
|
||||
options: CreateItemOptions
|
||||
): Promise<SeededItem> {
|
||||
const res = await api.post('/api/admin/items', {
|
||||
// multipart rather than JSON: the route accepts image uploads, so it reads
|
||||
// its fields from a multipart body even when no image is attached.
|
||||
multipart: {
|
||||
name: options.name,
|
||||
description: options.description ?? '',
|
||||
price: options.price ?? '50',
|
||||
category_id: options.categoryId == null ? '' : String(options.categoryId),
|
||||
tags: JSON.stringify(options.tags ?? [])
|
||||
}
|
||||
});
|
||||
expect(res.ok(), `creating item ${options.name}`).toBeTruthy();
|
||||
const item = { id: (await res.json()).id as number, name: options.name };
|
||||
|
||||
if (options.publish ?? true) {
|
||||
await publishItem(api, item.id);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function publishItem(api: APIRequestContext, id: number): Promise<void> {
|
||||
const res = await api.post(`/api/admin/items/${id}/mark-available`);
|
||||
expect(res.ok(), `publishing item ${id}`).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function sellItem(api: APIRequestContext, id: number): Promise<void> {
|
||||
const res = await api.post(`/api/admin/items/${id}/mark-sold`);
|
||||
expect(res.ok(), `selling item ${id}`).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function createCategory(
|
||||
api: APIRequestContext,
|
||||
name: string,
|
||||
parentId: number | null = null
|
||||
): Promise<number> {
|
||||
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
|
||||
expect(res.status(), `creating category ${name}`).toBe(201);
|
||||
return (await res.json()).id as number;
|
||||
}
|
||||
|
||||
export async function createTag(api: APIRequestContext, name: string): Promise<number> {
|
||||
const res = await api.post('/api/admin/tags', { data: { name } });
|
||||
expect(res.ok(), `creating tag ${name}`).toBeTruthy();
|
||||
return (await res.json()).id as number;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Client } from 'pg';
|
||||
|
||||
/**
|
||||
* Direct database access for the tests, for the one thing the API deliberately
|
||||
* will not give them.
|
||||
*
|
||||
* The password-reset token is only ever delivered by email, which these tests
|
||||
* cannot read. It is read from the database rather than through a helper
|
||||
* endpoint because an endpoint that returns a 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.
|
||||
*
|
||||
* That reasoning is unchanged from when it lived inline in
|
||||
* password-reset.spec.ts. What has changed is that it is no longer sitting in a
|
||||
* spec where it can be copied into the next one that wants a shortcut.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The default port is 55500, matching scripts/start-local.ps1.
|
||||
*
|
||||
* It used to be 55432, which is the integration suite's disposable Postgres —
|
||||
* a different database, with different credentials, that the app under test is
|
||||
* not connected to. Worse, 55432 is reserved by Hyper-V on at least one machine
|
||||
* here, so the spec failed with a bare ECONNREFUSED naming a port nobody had
|
||||
* chosen. TEST_PGPORT still overrides, for CI and for anyone running the stack
|
||||
* somewhere else.
|
||||
*/
|
||||
function connectionSettings() {
|
||||
return {
|
||||
host: process.env.TEST_PGHOST || 'localhost',
|
||||
port: parseInt(process.env.TEST_PGPORT || '55500', 10),
|
||||
user: process.env.TEST_PGUSER || 'redefined_local',
|
||||
password: process.env.TEST_PGPASSWORD || 'redefined_local',
|
||||
database: process.env.TEST_PGDATABASE || 'redefined_local'
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a connection, runs the query, and closes it whatever happens. */
|
||||
async function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client(connectionSettings());
|
||||
await client.connect();
|
||||
try {
|
||||
return await run(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent password-reset token issued to an address.
|
||||
*
|
||||
* Throws rather than returning null: every caller is about to build a URL from
|
||||
* it, and a missing token means the request under test did not do what it said,
|
||||
* which is worth failing loudly at the point it happened.
|
||||
*/
|
||||
export async function readPasswordResetToken(email: string): Promise<string> {
|
||||
return withClient(async (client) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"//": "Type-checks tests/. Separate from tsconfig.json rather than widening its `include`, because scripts/check-sonar-tsconfig.js compares the two configs' `include` arrays and tsconfig.sonar.json exists only so SonarQube 9.9 can build a program over src — pulling the Playwright suite into that analysis is a different decision from type-checking it. Run by `npm run build`; see #137.",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user