diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index b7e8ac7..bcc3fc0 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -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', + }, } ); diff --git a/frontend/package.json b/frontend/package.json index 2e17c89..156a2d4 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index 3cab96d..285334e 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -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({ + 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(); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts new file mode 100644 index 0000000..ef3194c --- /dev/null +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -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 { + await this.page.goto('/account'); + await expect(this.dialog).toBeVisible(); + } + + async logOut(): Promise { + 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); + } +} diff --git a/frontend/tests/e2e/pages/AdminPage.ts b/frontend/tests/e2e/pages/AdminPage.ts new file mode 100644 index 0000000..06f8db6 --- /dev/null +++ b/frontend/tests/e2e/pages/AdminPage.ts @@ -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 { + 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 { + await this.goto(); + await this.openTab(name); + } + + /** Switches tabs without renavigating, for a test that visits two of them. */ + async openTab(name: AdminTab): Promise { + 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(); + } +} diff --git a/frontend/tests/e2e/pages/AuthModal.ts b/frontend/tests/e2e/pages/AuthModal.ts new file mode 100644 index 0000000..f32d176 --- /dev/null +++ b/frontend/tests/e2e/pages/AuthModal.ts @@ -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 { + await this.page.goto('/register'); + } + + async gotoLogIn(): Promise { + 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 { + 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 { + await this.createAccountButton.click(); + } + + async fillCredentials(email: string, password: string): Promise { + await this.email.fill(email); + await this.password.fill(password); + } + + async submitLogIn(): Promise { + 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 { + await expect(this.registerDialog).toHaveCount(0); + await expect(this.logInDialog).toHaveCount(0); + } +} diff --git a/frontend/tests/e2e/pages/Header.ts b/frontend/tests/e2e/pages/Header.ts new file mode 100644 index 0000000..4e395c1 --- /dev/null +++ b/frontend/tests/e2e/pages/Header.ts @@ -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 { + await expect(this.myAccountButton).toBeVisible({ timeout: 20000 }); + } + + async waitForSignedOut(): Promise { + await expect(this.myAccountButton).toHaveCount(0); + } +} diff --git a/frontend/tests/e2e/pages/StorefrontPage.ts b/frontend/tests/e2e/pages/StorefrontPage.ts new file mode 100644 index 0000000..01b3fc1 --- /dev/null +++ b/frontend/tests/e2e/pages/StorefrontPage.ts @@ -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 { + 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 { + 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 { + 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 { + await expect(this.page.locator('.item-card').first()).toBeVisible(); + } +} diff --git a/frontend/tests/e2e/support/api.ts b/frontend/tests/e2e/support/api.ts new file mode 100644 index 0000000..f51174c --- /dev/null +++ b/frontend/tests/e2e/support/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const res = await api.post('/api/admin/tags', { data: { name } }); + expect(res.ok(), `creating tag ${name}`).toBeTruthy(); + return (await res.json()).id as number; +} diff --git a/frontend/tests/e2e/support/db.ts b/frontend/tests/e2e/support/db.ts new file mode 100644 index 0000000..7d31daf --- /dev/null +++ b/frontend/tests/e2e/support/db.ts @@ -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(run: (client: Client) => Promise): Promise { + 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 { + 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; + }); +} diff --git a/frontend/tsconfig.test.json b/frontend/tsconfig.test.json new file mode 100644 index 0000000..1b3cc75 --- /dev/null +++ b/frontend/tsconfig.test.json @@ -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"] +}