diff --git a/frontend/tests/e2e/admin-disable-customer.spec.ts b/frontend/tests/e2e/admin-disable-customer.spec.ts index 6d0748a..22464d7 100644 --- a/frontend/tests/e2e/admin-disable-customer.spec.ts +++ b/frontend/tests/e2e/admin-disable-customer.spec.ts @@ -1,65 +1,40 @@ -import { test, expect, Page } from './fixtures'; - -const PASSWORD = 'supersecret123'; -const uniqueEmail = () => `disable-${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 }); -} - -async function customerRow(page: Page, email: string) { - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Customers' }).click(); - const row = page.getByRole('row').filter({ hasText: email }); - await expect(row).toBeVisible(); - return row; -} +import { test, expect, createItem, uniqueSuffix } from './fixtures'; test.describe('Disabling a customer account', () => { - test('an admin can disable an account and the customer is told at sign-in', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); + test('an admin can disable an account and the customer is told at sign-in', async ({ + page, + customer, + admin, + adminCustomers, + authModal + }) => { + await admin.open('Customers'); + await expect(adminCustomers.row(customer.email)).toBeVisible(); + await expect(adminCustomers.status(customer.email, 'ACTIVE')).toBeVisible(); - const row = await customerRow(page, email); - await expect(row.getByText('ACTIVE')).toBeVisible(); - - await row.getByRole('button', { name: 'Disable' }).click(); - await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click(); + await adminCustomers.disable(customer.email); await expect(page.getByText('Account disabled')).toBeVisible(); - - const updated = page.getByRole('row').filter({ hasText: email }); - await expect(updated.getByText('DISABLED')).toBeVisible(); + await expect(adminCustomers.status(customer.email, 'DISABLED')).toBeVisible(); // A generic credential error would send a real customer round the // password-reset loop forever. - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - 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 authModal.gotoLogIn(); + await authModal.logIn(customer.email, customer.password); await expect(page.getByText(/disabled/i)).toBeVisible(); }); - test('an existing session stops working immediately', async ({ page, request }) => { - const email = uniqueEmail(); - await register(page, email); - + test('an existing session stops working immediately', async ({ + page, + request, + customer, + accountModal + }) => { // Still signed in from registration, in this same browser context. - await page.goto('/account'); - await expect(page.getByText(email)).toBeVisible(); + await accountModal.open(); + await expect(accountModal.emailText(customer.email)).toBeVisible(); const customers = await (await request.get('/api/admin/customers')).json(); - const id = customers.find((c: { email: string }) => c.email === email).id; + const id = customers.find((c: { email: string }) => c.email === customer.email).id; await request.post(`/api/admin/customers/${id}/disable`); // The cookie is unchanged, so this proves the server rejects it rather @@ -68,51 +43,49 @@ test.describe('Disabling a customer account', () => { await expect(page).toHaveURL(/\/login/); }); - test('re-enabling restores sign-in', async ({ page, request }) => { - const email = uniqueEmail(); - await register(page, email); - + test('re-enabling restores sign-in', async ({ + page, + request, + customer, + admin, + adminCustomers, + authModal, + header + }) => { const customers = await (await request.get('/api/admin/customers')).json(); - const id = customers.find((c: { email: string }) => c.email === email).id; + const id = customers.find((c: { email: string }) => c.email === customer.email).id; await request.post(`/api/admin/customers/${id}/disable`); - const row = await customerRow(page, email); - await row.getByRole('button', { name: 'Re-enable' }).click(); - await page.getByRole('dialog').getByRole('button', { name: 'Re-enable' }).click(); + await admin.open('Customers'); + await expect(adminCustomers.row(customer.email)).toBeVisible(); + await adminCustomers.reEnable(customer.email); await expect(page.getByText('Account re-enabled')).toBeVisible(); - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - 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.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, customer.password); + await header.waitForSignedIn(); }); - test('the confirmation warns that held items will be released', async ({ page, request }) => { - const email = uniqueEmail(); - const itemName = `Held ${Date.now().toString(36)}`; + test('the confirmation warns that held items will be released', async ({ + page, + request, + customer, + admin, + adminCustomers + }) => { + const item = await createItem(request, { name: `Held ${uniqueSuffix()}`, price: '40' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); - const created = await request.post('/api/admin/items', { - multipart: { name: itemName, description: '', price: '40', category_id: '', tags: '[]' } - }); - const itemId = (await created.json()).id as number; - // New items are pending, and a pending item cannot be added to a cart. - expect((await request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy(); - - await register(page, email); - expect((await page.request.post(`/api/cart/items/${itemId}`)).status()).toBe(201); - - const row = await customerRow(page, email); - await row.getByRole('button', { name: 'Disable' }).click(); + await admin.open('Customers'); + await expect(adminCustomers.row(customer.email)).toBeVisible(); + await adminCustomers.disableButton(customer.email).click(); // The consequence has to be visible at the moment of the decision. await expect(page.getByText(/1 reserved item/)).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click(); + await adminCustomers.confirmDialog.getByRole('button', { name: 'Disable' }).click(); await expect(page.getByText('Account disabled')).toBeVisible(); - const item = await (await request.get(`/api/items/${itemId}`)).json(); - expect(item.status).toBe('available'); + const released = await (await request.get(`/api/items/${item.id}`)).json(); + expect(released.status).toBe('available'); }); }); diff --git a/frontend/tests/e2e/admin-email-settings.spec.ts b/frontend/tests/e2e/admin-email-settings.spec.ts index cf820be..4cb90b8 100644 --- a/frontend/tests/e2e/admin-email-settings.spec.ts +++ b/frontend/tests/e2e/admin-email-settings.spec.ts @@ -12,33 +12,27 @@ const DEFAULTS = { greetingFallback: 'Hi,' }; -async function openSettings(page: import('@playwright/test').Page) { - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Settings' }).click(); - await expect(page.getByRole('heading', { name: 'Link lifetimes' })).toBeVisible(); -} - test.describe('The email settings', () => { - test.afterEach(async ({ page }) => { + test.afterEach(async ({ page, adminSettings }) => { await page.request.put('/api/admin/settings', { data: DEFAULTS }); }); - test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page }) => { - await openSettings(page); + test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page, adminSettings }) => { + await adminSettings.open(); // antd renders a stepped InputNumber to the step's precision, so "24.0". - await expect(page.getByLabel('Cart expiry (hours)')).toHaveValue('24.0'); - await expect(page.getByLabel('Email verification link (hours)')).toHaveValue('24.0'); - await expect(page.getByLabel('Password reset link (hours)')).toHaveValue('1.0'); - await expect(page.getByLabel('Greeting format')).toHaveValue('Hi {{firstName}},'); - await expect(page.getByLabel('Fallback, when there is no first name')).toHaveValue('Hi,'); + await expect(adminSettings.cartExpiryHours).toHaveValue('24.0'); + await expect(adminSettings.verifyTokenHours).toHaveValue('24.0'); + await expect(adminSettings.passwordResetHours).toHaveValue('1.0'); + await expect(adminSettings.greetingFormat).toHaveValue('Hi {{firstName}},'); + await expect(adminSettings.greetingFallback).toHaveValue('Hi,'); }); - test('saves a new lifetime, and the server keeps it', async ({ page }) => { - await openSettings(page); + test('saves a new lifetime, and the server keeps it', async ({ page, adminSettings }) => { + await adminSettings.open(); - await page.getByLabel('Password reset link (hours)').fill('3'); - await page.getByRole('button', { name: 'Save' }).click(); + await adminSettings.passwordResetHours.fill('3'); + await adminSettings.saveButton.click(); await expect(page.getByText('Settings saved')).toBeVisible(); const stored = await (await page.request.get('/api/admin/settings')).json(); @@ -47,33 +41,29 @@ test.describe('The email settings', () => { // The whole reason the placeholder exists: the sentence in the email is // rendered from the setting rather than written out beside it. - test('the password reset preview states the configured lifetime', async ({ page }) => { + test('the password reset preview states the configured lifetime', async ({ page, admin, adminEmails }) => { await page.request.put('/api/admin/settings', { data: { passwordResetHours: 2 } }); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Emails' }).click(); - await page.getByRole('tab', { name: /Password reset/ }).click(); + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); - const preview = page.frameLocator('iframe[title="Password reset preview"]'); - await expect(preview.getByText('2 hours')).toBeVisible(); + await expect(adminEmails.preview('Password reset').getByText('2 hours')).toBeVisible(); }); - test('the preview greets through the configured format', async ({ page }) => { + test('the preview greets through the configured format', async ({ page, admin, adminEmails }) => { await page.request.put('/api/admin/settings', { data: { greetingFormat: 'Salutations {{firstName}}!' } }); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Emails' }).click(); - await page.getByRole('tab', { name: /Email verification/ }).click(); + await admin.open('Emails'); + await adminEmails.openTemplate('Email verification'); - const preview = page.frameLocator('iframe[title="Email verification preview"]'); - await expect(preview.getByText('Salutations Ada!')).toBeVisible(); + await expect(adminEmails.preview('Email verification').getByText('Salutations Ada!')).toBeVisible(); }); - test('refuses a lifetime of zero rather than reporting a save', async ({ page }) => { - await openSettings(page); + test('refuses a lifetime of zero rather than reporting a save', async ({ page, adminSettings }) => { + await adminSettings.open(); - await page.getByLabel('Password reset link (hours)').fill('0'); - await page.getByRole('button', { name: 'Save' }).click(); + await adminSettings.passwordResetHours.fill('0'); + await adminSettings.saveButton.click(); await expect(page.getByText('Settings saved')).toHaveCount(0); }); diff --git a/frontend/tests/e2e/admin-inline-category.spec.ts b/frontend/tests/e2e/admin-inline-category.spec.ts index e48f2af..4693f1a 100644 --- a/frontend/tests/e2e/admin-inline-category.spec.ts +++ b/frontend/tests/e2e/admin-inline-category.spec.ts @@ -1,48 +1,34 @@ -import { test, expect } from './fixtures'; - -const suffix = () => `i${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; - -// Opening the form kicks off a categories fetch. Interacting with the category -// field before it lands means the tree re-renders under the cursor, so wait for -// the data rather than racing it. -async function openItemForm(page: import('@playwright/test').Page) { - // Opening the form fetches both categories and tags, and each one re-renders - // the modal as it lands. Waiting for only one still leaves the second to - // reflow the popup mid-interaction. - const loaded = Promise.all([ - page.waitForResponse((res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'), - page.waitForResponse((res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET') - ]); - await page.getByRole('button', { name: 'Add Item' }).click(); - await loaded; -} +import { test, expect, uniqueSuffix, createCategory } from './fixtures'; test.describe('Inline category creation from the item form', () => { - test('creates a category without leaving the item form and assigns it', async ({ page }) => { - const RUN = suffix(); + test('creates a category without leaving the item form and assigns it', async ({ + page, + admin, + adminInventory + }) => { + const RUN = `i${uniqueSuffix()}`; const categoryName = `Inline ${RUN}`; const itemName = `Item ${RUN}`; - await page.goto('/admin'); - await openItemForm(page); - await page.getByLabel('Name').fill(itemName); - await page.getByLabel('Price (USD)').fill('99'); + await admin.goto(); + await adminInventory.openItemFormLoaded(); + await adminInventory.name.fill(itemName); + await adminInventory.price.fill('99'); - await page.getByRole('dialog').getByLabel('Category', { exact: true }).click(); - const nameInput = page.getByPlaceholder('New category name'); - await expect(nameInput).toBeVisible(); - await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible(); - await nameInput.fill(categoryName); + await adminInventory.categoryField.click(); + await expect(adminInventory.newCategoryName).toBeVisible(); + await expect(adminInventory.createCategoryButton).toBeVisible(); + await adminInventory.newCategoryName.fill(categoryName); // Submitted with Enter rather than a click: the popup sits over a // virtualized tree that keeps re-measuring, so a click target inside it is // never geometrically stable. Enter runs the same handler as the button. - await nameInput.press('Enter'); + await adminInventory.newCategoryName.press('Enter'); // The new category should be selected straight away — having to hunt for it // in the tree afterwards defeats the point of creating it inline. - await expect(page.getByRole('dialog').getByText(categoryName)).toBeVisible(); + await expect(adminInventory.formDialog.getByText(categoryName)).toBeVisible(); - await page.getByRole('button', { name: 'OK' }).click(); + await adminInventory.confirmButton.click(); await expect(page.getByText('Item added')).toBeVisible(); const items = await (await page.request.get('/api/admin/items')).json(); @@ -51,26 +37,21 @@ test.describe('Inline category creation from the item form', () => { expect(saved.category_name).toBe(categoryName); }); - test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => { - const RUN = suffix(); - const categoryName = `Dupe ${RUN}`; + test('reports a duplicate category name instead of silently doing nothing', async ({ + page, + admin, + adminInventory + }) => { + const categoryName = `Dupe i${uniqueSuffix()}`; + await createCategory(page.request, categoryName); - const created = await page.request.post('/api/admin/categories', { - data: { name: categoryName, parent_id: null } - }); - expect(created.status()).toBe(201); - - await page.goto('/admin'); - await openItemForm(page); - await page.getByRole('dialog').getByLabel('Category', { exact: true }).click(); - const nameInput = page.getByPlaceholder('New category name'); - await expect(nameInput).toBeVisible(); - await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible(); - await nameInput.fill(categoryName); - // Submitted with Enter rather than a click: the popup sits over a - // virtualized tree that keeps re-measuring, so a click target inside it is - // never geometrically stable. Enter runs the same handler as the button. - await nameInput.press('Enter'); + await admin.goto(); + await adminInventory.openItemFormLoaded(); + await adminInventory.categoryField.click(); + await expect(adminInventory.newCategoryName).toBeVisible(); + await expect(adminInventory.createCategoryButton).toBeVisible(); + await adminInventory.newCategoryName.fill(categoryName); + await adminInventory.newCategoryName.press('Enter'); await expect(page.getByText(/already exists/i)).toBeVisible(); }); diff --git a/frontend/tests/e2e/admin-inventory-filters.spec.ts b/frontend/tests/e2e/admin-inventory-filters.spec.ts index 2c9d604..e579d3f 100644 --- a/frontend/tests/e2e/admin-inventory-filters.spec.ts +++ b/frontend/tests/e2e/admin-inventory-filters.spec.ts @@ -1,6 +1,6 @@ -import { test, expect, Page } from './fixtures'; +import { test, expect, createAdminContext, createCategory, createTag, createItem, uniqueSuffix } from './fixtures'; -const RUN = `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; +const RUN = `v${uniqueSuffix()}`; const NAMES = { category: `Filterable ${RUN}`, @@ -12,166 +12,115 @@ const NAMES = { }; test.beforeAll(async ({ playwright }) => { - const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); + const api = await createAdminContext(playwright); - const categoryId = (await (await api.post('/api/admin/categories', { - data: { name: NAMES.category, parent_id: null } - })).json()).id; - await api.post('/api/admin/tags', { data: { name: NAMES.tag } }); + const categoryId = await createCategory(api, NAMES.category); + await createTag(api, NAMES.tag); - // Published after creation: new items are pending, and these fixtures stand - // in for ordinary stock rather than staged drafts. - const item = async (name: string, price: string, inCategory: boolean, publish = true) => { - const res = await api.post('/api/admin/items', { - multipart: { - name, - description: '', - price, - category_id: inCategory ? String(categoryId) : '', - tags: JSON.stringify(inCategory ? [NAMES.tag] : []) - } - }); - if (publish) { - await api.post(`/api/admin/items/${(await res.json()).id}/mark-available`); - } - }; - - await item(NAMES.cheap, '50', true); - await item(NAMES.mid, '150', true); - await item(NAMES.dear, '900', true); + const inCategory = { categoryId, tags: [NAMES.tag] }; + await createItem(api, { name: NAMES.cheap, price: '50', ...inCategory }); + await createItem(api, { name: NAMES.mid, price: '150', ...inCategory }); + await createItem(api, { name: NAMES.dear, price: '900', ...inCategory }); // Left pending on purpose: the Unpublished filter needs something to find, // and every other fixture here is published. - await item(NAMES.staged, '400', true, false); + await createItem(api, { name: NAMES.staged, price: '400', ...inCategory, publish: false }); await api.dispose(); }); -// Toggles one status in the multi-select. Clicking an option that is already -// selected removes it, which is what the clearing test relies on. -// -// Two antd details decide this locator. It renders an invisible role="listbox" -// shim beside the real list for accessibility, so getByRole('option') finds -// something zero-sized that cannot be clicked. And once a status is selected it -// also renders as a tag carrying the same title as the option, so an unscoped -// getByTitle becomes ambiguous. Matching the visible option class avoids both. -// -// The dropdown is opened only when it is not already open: antd keeps it open -// after a selection in multiple mode, so clicking the box again would close it. -async function chooseStatus(page: Page, label: string) { - const option = page.locator(`.ant-select-item-option[title="${label}"]`); - if (!(await option.isVisible().catch(() => false))) { - await page.getByRole('combobox', { name: 'Filter by status' }).click(); - } - await option.click(); -} - -const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name }); - -// The Inventory table paginates and other specs create items concurrently, so -// an unfiltered page 1 is not a reliable place to look for a fixture. Every -// assertion below therefore runs against a category filter that narrows the -// table to this spec's own items. -async function filterToOwnCategory(page: Page) { - const category = page.getByRole('combobox', { name: 'Filter by category' }); - await category.click(); - await category.fill(NAMES.category); - await page.getByTitle(NAMES.category, { exact: true }).click(); - await expect(row(page, NAMES.cheap)).toBeVisible(); -} - test.describe('Admin inventory filters', () => { - test('filters by category', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); + test('filters by category', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); // All three fixtures share the category, and nothing else does. - await expect(row(page, NAMES.cheap)).toBeVisible(); - await expect(row(page, NAMES.mid)).toBeVisible(); - await expect(row(page, NAMES.dear)).toBeVisible(); + await expect(adminInventory.row(NAMES.cheap)).toBeVisible(); + await expect(adminInventory.row(NAMES.mid)).toBeVisible(); + await expect(adminInventory.row(NAMES.dear)).toBeVisible(); }); - test('filters by price range', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); + test('filters by price range', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); - await page.getByLabel('Minimum price').fill('100'); - await page.getByLabel('Maximum price').fill('500'); + await adminInventory.minimumPrice.fill('100'); + await adminInventory.maximumPrice.fill('500'); - await expect(row(page, NAMES.mid)).toBeVisible(); - await expect(row(page, NAMES.cheap)).toHaveCount(0); - await expect(row(page, NAMES.dear)).toHaveCount(0); + await expect(adminInventory.row(NAMES.mid)).toBeVisible(); + await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0); + await expect(adminInventory.row(NAMES.dear)).toHaveCount(0); }); - test('filters by a single status', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); + test('filters by a single status', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); - await chooseStatus(page, 'Sold'); + await adminInventory.toggleStatus('Sold'); // Every fixture is published and unsold, so a Sold filter excludes them all. - await expect(row(page, NAMES.cheap)).toHaveCount(0); - await expect(row(page, NAMES.mid)).toHaveCount(0); - await expect(row(page, NAMES.dear)).toHaveCount(0); + await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0); + await expect(adminInventory.row(NAMES.mid)).toHaveCount(0); + await expect(adminInventory.row(NAMES.dear)).toHaveCount(0); }); // The question that prompted #132. Every item arrives pending since #90, so // "what is waiting for me to publish" is routine, and the preset this control // replaced could not ask it. - test('finds unpublished items, and only those', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); + test('finds unpublished items, and only those', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); - await chooseStatus(page, 'Pending'); + await adminInventory.toggleStatus('Pending'); - await expect(row(page, NAMES.staged)).toBeVisible(); - await expect(row(page, NAMES.cheap)).toHaveCount(0); - await expect(row(page, NAMES.mid)).toHaveCount(0); - await expect(row(page, NAMES.dear)).toHaveCount(0); + await expect(adminInventory.row(NAMES.staged)).toBeVisible(); + await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0); + await expect(adminInventory.row(NAMES.mid)).toHaveCount(0); + await expect(adminInventory.row(NAMES.dear)).toHaveCount(0); }); // The complement, and the case a two-way preset could not express either: // published means three statuses at once, not one and not "everything else". - test('finds published items by selecting several statuses at once', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); + test('finds published items by selecting several statuses at once', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); - await chooseStatus(page, 'Available'); - await chooseStatus(page, 'Reserved'); - await chooseStatus(page, 'Sold'); + await adminInventory.toggleStatus('Available'); + await adminInventory.toggleStatus('Reserved'); + await adminInventory.toggleStatus('Sold'); - await expect(row(page, NAMES.cheap)).toBeVisible(); - await expect(row(page, NAMES.mid)).toBeVisible(); - await expect(row(page, NAMES.dear)).toBeVisible(); - await expect(row(page, NAMES.staged)).toHaveCount(0); + await expect(adminInventory.row(NAMES.cheap)).toBeVisible(); + await expect(adminInventory.row(NAMES.mid)).toBeVisible(); + await expect(adminInventory.row(NAMES.dear)).toBeVisible(); + await expect(adminInventory.row(NAMES.staged)).toHaveCount(0); }); // Cleared back to nothing must mean "no filter" rather than "no statuses", // or emptying the box would empty the table. - test('clearing the status shows everything again', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); - await chooseStatus(page, 'Pending'); - await expect(row(page, NAMES.cheap)).toHaveCount(0); + test('clearing the status shows everything again', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); + await adminInventory.toggleStatus('Pending'); + await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0); - await chooseStatus(page, 'Pending'); + await adminInventory.toggleStatus('Pending'); - await expect(row(page, NAMES.cheap)).toBeVisible(); - await expect(row(page, NAMES.staged)).toBeVisible(); + await expect(adminInventory.row(NAMES.cheap)).toBeVisible(); + await expect(adminInventory.row(NAMES.staged)).toBeVisible(); }); - test('combines filters, and clearing restores them', async ({ page }) => { - await page.goto('/admin'); - await filterToOwnCategory(page); - await page.getByLabel('Minimum price').fill('800'); - await expect(row(page, NAMES.cheap)).toHaveCount(0); - await expect(row(page, NAMES.dear)).toBeVisible(); + test('combines filters, and clearing restores them', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.filterByCategory(NAMES.category, NAMES.cheap); + await adminInventory.minimumPrice.fill('800'); + await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0); + await expect(adminInventory.row(NAMES.dear)).toBeVisible(); - await page.getByRole('button', { name: 'Clear filters' }).click(); + await adminInventory.clearFiltersButton.click(); // Asserting on the controls rather than on the rows: with the filters gone // the table is the whole paginated catalogue again, so a given fixture is // not reliably on the first page. - await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0); - await expect(page.getByLabel('Minimum price')).toHaveValue(''); + await expect(adminInventory.clearFiltersButton).toHaveCount(0); + await expect(adminInventory.minimumPrice).toHaveValue(''); }); }); diff --git a/frontend/tests/e2e/admin-item-preview.spec.ts b/frontend/tests/e2e/admin-item-preview.spec.ts index f14a6fa..3a512da 100644 --- a/frontend/tests/e2e/admin-item-preview.spec.ts +++ b/frontend/tests/e2e/admin-item-preview.spec.ts @@ -1,29 +1,16 @@ -import { test, expect } from './fixtures'; +import { test, expect, createItem, uniqueSuffix } from './fixtures'; -const suffix = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; - -async function createItem(page: import('@playwright/test').Page, name: string, price: string) { - const created = await page.request.post('/api/admin/items', { - multipart: { name, description: 'A preview subject', price, category_id: '', tags: '[]' } - }); - expect(created.ok()).toBeTruthy(); - const id = (await created.json()).id as number; - // New items are pending. Published here so the card renders the same states - // a customer would see; the pending case is covered in the pending spec. - const published = await page.request.post(`/api/admin/items/${id}/mark-available`); - expect(published.ok()).toBeTruthy(); - return id; -} +const DESCRIPTION = 'A preview subject'; test.describe('Admin item preview', () => { - test('opens the storefront card from the item name', async ({ page }) => { - const name = `Preview ${suffix()}`; - await createItem(page, name, '42'); + test('opens the storefront card from the item name', async ({ page, admin, adminInventory }) => { + const name = `Preview p${uniqueSuffix()}`; + await createItem(page.request, { name, price: '42', description: DESCRIPTION }); - await page.goto('/admin'); - await page.getByRole('button', { name }).click(); + await admin.goto(); + await adminInventory.openPreview(name); - const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); + const drawer = adminInventory.previewDrawer(name); await expect(drawer).toBeVisible(); // The card itself, not just an empty panel: the storefront renders the @@ -34,15 +21,18 @@ test.describe('Admin item preview', () => { // The claim the preview has to make: it looks live, and it is not. The // buttons must keep their normal appearance rather than being disabled, // because showing a customer's view is the entire purpose of the panel. - test('shows the Add to Cart button in its normal enabled state', async ({ page }) => { - const name = `Preview ${suffix()}`; - await createItem(page, name, '15'); + test('shows the Add to Cart button in its normal enabled state', async ({ + page, + admin, + adminInventory + }) => { + const name = `Preview p${uniqueSuffix()}`; + await createItem(page.request, { name, price: '15', description: DESCRIPTION }); - await page.goto('/admin'); - await page.getByRole('button', { name }).click(); + await admin.goto(); + await adminInventory.openPreview(name); - const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); - const addToCart = drawer.getByRole('button', { name: 'Add to Cart' }); + const addToCart = adminInventory.previewDrawer(name).getByRole('button', { name: 'Add to Cart' }); await expect(addToCart).toBeVisible(); await expect(addToCart).toBeEnabled(); @@ -52,14 +42,14 @@ test.describe('Admin item preview', () => { // at the screen. Signed out, a real Add to Cart opens the sign-in prompt // before it can add anything — so if that modal never appears, the handler // short-circuited before reaching any of its real work. - test('does not act when the preview card is clicked', async ({ page }) => { - const name = `Preview ${suffix()}`; - await createItem(page, name, '99'); + test('does not act when the preview card is clicked', async ({ page, admin, adminInventory }) => { + const name = `Preview p${uniqueSuffix()}`; + await createItem(page.request, { name, price: '99', description: DESCRIPTION }); - await page.goto('/admin'); - await page.getByRole('button', { name }).click(); + await admin.goto(); + await adminInventory.openPreview(name); - const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); + const drawer = adminInventory.previewDrawer(name); await drawer.getByRole('button', { name: 'Add to Cart' }).click(); // Nothing succeeded and nothing prompted. @@ -73,15 +63,14 @@ test.describe('Admin item preview', () => { // The storefront card must stay live where it is actually used, or this // change would have quietly broken buying things. - test('leaves the real storefront card working', async ({ page }) => { - const name = `Live ${suffix()}`; - await createItem(page, name, '20'); + test('leaves the real storefront card working', async ({ page, storefront }) => { + const name = `Live p${uniqueSuffix()}`; + await createItem(page.request, { name, price: '20', description: DESCRIPTION }); - await page.goto('/'); - const card = page.locator('.ant-card').filter({ hasText: name }); - await expect(card).toBeVisible(); + await storefront.goto(); + await expect(storefront.card(name)).toBeVisible(); - await card.getByRole('button', { name: 'Add to Cart' }).click(); + await storefront.addToCartButton(name).click(); // Signed out, the real card prompts for sign-in — proof the handler ran. await expect(page.getByRole('dialog')).toBeVisible(); diff --git a/frontend/tests/e2e/admin-reserved-items.spec.ts b/frontend/tests/e2e/admin-reserved-items.spec.ts index d5c6f77..6ae7c1c 100644 --- a/frontend/tests/e2e/admin-reserved-items.spec.ts +++ b/frontend/tests/e2e/admin-reserved-items.spec.ts @@ -1,61 +1,52 @@ -import { test, expect } from './fixtures'; +import { test, expect, createItem, uniqueSuffix } from './fixtures'; -const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; - -// Reserves an item by registering a customer and adding it to their cart, which -// is the only way an item legitimately reaches 'reserved'. -async function reserveItem(page: import('@playwright/test').Page, itemName: string) { - const email = `reserve-${suffix()}@example.com`; - - const created = await page.request.post('/api/admin/items', { - multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' } - }); - expect(created.ok()).toBeTruthy(); - const itemId = (await created.json()).id as number; - // New items are pending, and a pending item cannot be reserved. - expect((await page.request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy(); - - 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('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - - const added = await page.request.post(`/api/cart/items/${itemId}`); - expect(added.status()).toBe(201); - - return { email, itemId }; +/** + * Puts an item in the signed-in customer's cart, which is the only way an item + * legitimately reaches 'reserved'. + * + * The `customer` fixture supplies the session, so this only has to seed the + * item and add it — a pending item cannot be reserved, hence publishing first, + * which `createItem` does by default. + */ +async function reserve( + request: import('@playwright/test').APIRequestContext, + itemName: string +): Promise { + const item = await createItem(request, { name: itemName, price: '75' }); + expect((await request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + return item.id; } test.describe('Admin reserved items', () => { - test('shows a reserved count that opens the held items', async ({ page }) => { - const itemName = `Held ${suffix()}`; - const { email } = await reserveItem(page, itemName); + test('shows a reserved count that opens the held items', async ({ + page, + customer, + admin, + adminCustomers + }) => { + const itemName = `Held r${uniqueSuffix()}`; + await reserve(page.request, itemName); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Customers' }).click(); + await admin.open('Customers'); + await expect(adminCustomers.row(customer.email)).toBeVisible(); + await adminCustomers.reservedCountButton(customer.email).click(); - const row = page.getByRole('row').filter({ hasText: email }); - await expect(row).toBeVisible(); - await row.getByRole('button', { name: /item/ }).click(); - - const dialog = page.getByRole('dialog', { name: /Items reserved by/ }); - await expect(dialog.getByText(itemName)).toBeVisible(); + await expect(adminCustomers.reservedItemsDialog.getByText(itemName)).toBeVisible(); }); - test('releasing an item returns it to the storefront as available', async ({ page }) => { - const itemName = `Freed ${suffix()}`; - const { email, itemId } = await reserveItem(page, itemName); + test('releasing an item returns it to the storefront as available', async ({ + page, + customer, + admin, + adminCustomers + }) => { + const itemName = `Freed r${uniqueSuffix()}`; + const itemId = await reserve(page.request, itemName); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Customers' }).click(); - const row = page.getByRole('row').filter({ hasText: email }); - await row.getByRole('button', { name: /item/ }).click(); + await admin.open('Customers'); + await adminCustomers.reservedCountButton(customer.email).click(); - const dialog = page.getByRole('dialog', { name: /Items reserved by/ }); - await dialog.getByRole('button', { name: 'Release' }).click(); + await adminCustomers.reservedItemsDialog.getByRole('button', { name: 'Release' }).click(); await expect(page.getByText(`Released "${itemName}"`)).toBeVisible(); // The point of releasing is that the item becomes purchasable again. @@ -63,38 +54,34 @@ test.describe('Admin reserved items', () => { expect(item.status).toBe('available'); }); - test('the count drops once the item is released', async ({ page }) => { - const itemName = `Recount ${suffix()}`; - const { email } = await reserveItem(page, itemName); + test('the count drops once the item is released', async ({ + page, + customer, + admin, + adminCustomers + }) => { + await reserve(page.request, `Recount r${uniqueSuffix()}`); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Customers' }).click(); - const row = page.getByRole('row').filter({ hasText: email }); - await row.getByRole('button', { name: /item/ }).click(); + await admin.open('Customers'); + await adminCustomers.reservedCountButton(customer.email).click(); - const dialog = page.getByRole('dialog', { name: /Items reserved by/ }); + const dialog = adminCustomers.reservedItemsDialog; await dialog.getByRole('button', { name: 'Release' }).click(); await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible(); // The row behind the dialog must agree with the dialog it opened. await dialog.getByRole('button', { name: 'Close' }).click(); - await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0); + await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0); }); - test('a customer holding nothing shows no link to click', async ({ page }) => { - const email = `idle-${suffix()}@example.com`; - 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('supersecret123'); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + test('a customer holding nothing shows no link to click', async ({ + customer, + admin, + adminCustomers + }) => { + await admin.open('Customers'); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Customers' }).click(); - const row = page.getByRole('row').filter({ hasText: email }); - await expect(row).toBeVisible(); - await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0); + await expect(adminCustomers.row(customer.email)).toBeVisible(); + await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0); }); }); diff --git a/frontend/tests/e2e/admin-save-failures.spec.ts b/frontend/tests/e2e/admin-save-failures.spec.ts index 58fb8c4..cfa68c5 100644 --- a/frontend/tests/e2e/admin-save-failures.spec.ts +++ b/frontend/tests/e2e/admin-save-failures.spec.ts @@ -1,9 +1,11 @@ -import { test, expect } from './fixtures'; - -const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; +import { test, expect, uniqueSuffix } from './fixtures'; test.describe('Admin save failures', () => { - test('does not claim an item was saved when the request failed', async ({ page }) => { + test('does not claim an item was saved when the request failed', async ({ + page, + admin, + adminInventory + }) => { await page.route('**/api/admin/items', (route) => { if (route.request().method() === 'POST') { return route.fulfill({ @@ -15,24 +17,25 @@ test.describe('Admin save failures', () => { return route.continue(); }); - await page.goto('/admin'); - await page.getByRole('button', { name: 'Add Item' }).click(); - await page.getByLabel('Name').fill(`Broken ${suffix()}`); - await page.getByLabel('Price (USD)').fill('12'); - await page.getByRole('button', { name: 'OK' }).click(); + await admin.goto(); + await adminInventory.addItem(`Broken ${uniqueSuffix()}`, '12'); // Reporting success for a failed save is worse than failing loudly: the // item is silently absent and the user has no reason to look for it. await expect(page.getByText('Item added')).toBeHidden(); await expect(page.getByText("Couldn't save item")).toBeVisible(); // The form must stay open so the entered values aren't lost. - await expect(page.getByRole('dialog')).toBeVisible(); + await expect(adminInventory.formDialog).toBeVisible(); }); - test('reports a failed delete rather than claiming success', async ({ page }) => { + test('reports a failed delete rather than claiming success', async ({ + page, + admin, + adminInventory + }) => { // Seeded through the API so the test owns a known row rather than clicking // whichever Delete button happens to be first in a paginated table. - const name = `Doomed ${suffix()}`; + const name = `Doomed ${uniqueSuffix()}`; const created = await page.request.post('/api/admin/items', { multipart: { name, description: '', price: '10', category_id: '', tags: '[]' } }); @@ -45,26 +48,26 @@ test.describe('Admin save failures', () => { return route.continue(); }); - await page.goto('/admin'); + await admin.goto(); // Items list newest-first, so the seeded row is on the first page. - const row = page.getByRole('row').filter({ hasText: name }); - await expect(row).toBeVisible(); - await row.getByRole('button', { name: 'Delete' }).click(); + await expect(adminInventory.row(name)).toBeVisible(); + await adminInventory.deleteButton(name).click(); await expect(page.getByText('Item deleted')).toBeHidden(); await expect(page.getByText("Couldn't delete item")).toBeVisible(); // The row must survive a failed delete. - await expect(row).toBeVisible(); + await expect(adminInventory.row(name)).toBeVisible(); }); - test('saves an item successfully when the server accepts it', async ({ page }) => { - const name = `Good ${suffix()}`; + test('saves an item successfully when the server accepts it', async ({ + page, + admin, + adminInventory + }) => { + const name = `Good ${uniqueSuffix()}`; - await page.goto('/admin'); - await page.getByRole('button', { name: 'Add Item' }).click(); - await page.getByLabel('Name').fill(name); - await page.getByLabel('Price (USD)').fill('34'); - await page.getByRole('button', { name: 'OK' }).click(); + await admin.goto(); + await adminInventory.addItem(name, '34'); await expect(page.getByText('Item added')).toBeVisible(); diff --git a/frontend/tests/e2e/admin-taxonomy.spec.ts b/frontend/tests/e2e/admin-taxonomy.spec.ts index f3d217b..5858abf 100644 --- a/frontend/tests/e2e/admin-taxonomy.spec.ts +++ b/frontend/tests/e2e/admin-taxonomy.spec.ts @@ -1,29 +1,23 @@ -import { test, expect } from './fixtures'; +import { test, expect, uniqueSuffix } from './fixtures'; // The e2e database is shared and never reset, so every fixture name carries a // unique suffix and assertions are scoped to the nodes this run created. The // suffix is generated per test rather than per module: a worker can run this // file more than once, and a module-level constant would collide with itself. -const suffix = () => `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; test.describe('Admin taxonomy', () => { - test('creates a category and a nested child that stays visible', async ({ page }) => { - const RUN = suffix(); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Categories' }).click(); + test('creates a category and a nested child that stays visible', async ({ + page, + admin, + adminTaxonomy + }) => { + const RUN = `a${uniqueSuffix()}`; + await admin.open('Categories'); - await page.getByRole('button', { name: 'Add Category' }).click(); - await page.getByLabel('Name').fill(`Furniture ${RUN}`); - await page.getByRole('button', { name: 'OK' }).click(); + await adminTaxonomy.addCategory(`Furniture ${RUN}`); await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible(); - await page - .getByRole('treeitem') - .filter({ hasText: `Furniture ${RUN}` }) - .getByRole('button', { name: 'Add child' }) - .click(); - await page.getByLabel('Name').fill(`Tables ${RUN}`); - await page.getByRole('button', { name: 'OK' }).click(); + await adminTaxonomy.addChildCategory(`Furniture ${RUN}`, `Tables ${RUN}`); // The tree is mounted before this branch exists, so the child is only // visible if expansion follows newly created nodes rather than the state @@ -31,17 +25,18 @@ test.describe('Admin taxonomy', () => { await expect(page.getByText(`Tables ${RUN}`)).toBeVisible(); }); - test('creates a tag with an automatically assigned colour', async ({ page }) => { - const RUN = suffix(); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Tags' }).click(); + test('creates a tag with an automatically assigned colour', async ({ + page, + admin, + adminTaxonomy + }) => { + const RUN = `a${uniqueSuffix()}`; + await admin.open('Tags'); - await page.getByRole('button', { name: 'Add Tag' }).click(); - await page.getByLabel('Name').fill(`vintage-${RUN}`); - await page.getByRole('button', { name: 'OK' }).click(); + await adminTaxonomy.addTag(`vintage-${RUN}`); // Clicking OK only dispatches the request; wait for the confirmation so the // lookup below can't race the create. - await expect(page.getByText('Tag added')).toBeVisible(); + await expect(adminTaxonomy.tagAddedNotice).toBeVisible(); // The table paginates and the shared database holds many tags, so the new // row is confirmed through the API rather than hunted for across pages. @@ -51,12 +46,11 @@ test.describe('Admin taxonomy', () => { expect(created.color).toBeTruthy(); }); - test('offers category and tag fields on the item form', async ({ page }) => { - await page.goto('/admin'); - await page.getByRole('button', { name: 'Add Item' }).click(); + test('offers category and tag fields on the item form', async ({ admin, adminInventory }) => { + await admin.goto(); + await adminInventory.openItemForm(); - const modal = page.getByRole('dialog'); - await expect(modal.getByText('Category', { exact: true })).toBeVisible(); - await expect(modal.getByText('Pick existing tags')).toBeVisible(); + await expect(adminInventory.formDialog.getByText('Category', { exact: true })).toBeVisible(); + await expect(adminInventory.formDialog.getByText('Pick existing tags')).toBeVisible(); }); }); diff --git a/frontend/tests/e2e/admin-theme.spec.ts b/frontend/tests/e2e/admin-theme.spec.ts index 523ebc6..8e1a2bd 100644 --- a/frontend/tests/e2e/admin-theme.spec.ts +++ b/frontend/tests/e2e/admin-theme.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from './fixtures'; +import { test, expect, AdminPage, createCategory, createTag, uniqueSuffix } from './fixtures'; // Relative luminance per WCAG, used to tell "light" from "dark" without // asserting exact hex values, which would break on any palette tweak. @@ -11,82 +11,65 @@ function luminance(rgb: string): number { return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } -const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; - // The Categories and Tags tabs render an empty state when there is nothing to // show, and the shared dev database gets truncated by the backend integration -// suite. Seed what each test needs rather than depending on what happens to be -// there. -async function seed(page: Page) { - const run = suffix(); - await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } }); - await page.request.post('/api/admin/tags', { data: { name: `theme-${run}` } }); -} - -async function switchToDark(page: Page) { - await page.goto('/admin'); - const toggle = page.getByRole('switch'); - if ((await toggle.getAttribute('aria-checked')) !== 'true') { - await toggle.click(); - } - await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark'); +// suite (#116). Seed what each test needs rather than depending on what happens +// to be there. +async function seed(request: import('@playwright/test').APIRequestContext) { + const run = uniqueSuffix(); + await createCategory(request, `Theme ${run}`); + await createTag(request, `theme-${run}`); } test.describe('Admin dark mode', () => { - test('the active tab label is readable against the dark background', async ({ page }) => { - await switchToDark(page); - - const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first(); + test('the active tab label is readable against the dark background', async ({ admin }) => { + await admin.switchToDark(); // The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was // near-black text on a near-black background. await expect - .poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color))) + .poll(async () => luminance(await AdminPage.colorOf(admin.activeTabLabel))) .toBeGreaterThan(0.5); }); - test('the Categories tab follows the dark theme', async ({ page }) => { - await seed(page); - await switchToDark(page); - await page.getByRole('tab', { name: 'Categories' }).click(); + test('the Categories tab follows the dark theme', async ({ page, admin }) => { + await seed(page.request); + await admin.switchToDark(); + await admin.openTab('Categories'); - const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first(); - await expect(tree).toBeVisible(); - const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(admin.activeTree).toBeVisible(); // The bug: deep imports from antd/lib loaded a second copy of antd that // never saw ConfigProvider, so this rendered pure white in dark mode. - expect(luminance(bg)).toBeLessThan(0.5); + expect(luminance(await AdminPage.backgroundOf(admin.activeTree))).toBeLessThan(0.5); }); - test('the Tags tab follows the dark theme', async ({ page }) => { - await seed(page); - await switchToDark(page); - await page.getByRole('tab', { name: 'Tags' }).click(); + test('the Tags tab follows the dark theme', async ({ page, admin }) => { + await seed(page.request); + await admin.switchToDark(); + await admin.openTab('Tags'); - const table = page.locator('.ant-tabs-tabpane-active .ant-table').first(); - await expect(table).toBeVisible(); - const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor); - expect(luminance(bg)).toBeLessThan(0.5); + await expect(admin.activeTable).toBeVisible(); + expect(luminance(await AdminPage.backgroundOf(admin.activeTable))).toBeLessThan(0.5); }); - test('the item form category selector follows the dark theme', async ({ page }) => { - await switchToDark(page); - await page.getByRole('button', { name: 'Add Item' }).click(); - await page.getByRole('dialog').getByLabel('Category', { exact: true }).click(); + test('the item form category selector follows the dark theme', async ({ + admin, + adminInventory + }) => { + await admin.switchToDark(); + await adminInventory.openItemForm(); + await adminInventory.categoryField.click(); - const popup = page.locator('.ant-select-dropdown').first(); - await expect(popup).toBeVisible(); - const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor); - expect(luminance(bg)).toBeLessThan(0.5); + await expect(admin.selectDropdown).toBeVisible(); + expect(luminance(await AdminPage.backgroundOf(admin.selectDropdown))).toBeLessThan(0.5); }); }); test.describe('Admin copy', () => { - test('uses American English spelling for color', async ({ page }) => { - await seed(page); - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Tags' }).click(); + test('uses American English spelling for color', async ({ page, admin }) => { + await seed(page.request); + await admin.open('Tags'); await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible(); await expect(page.getByText('Colour')).toHaveCount(0); diff --git a/frontend/tests/e2e/email-templates.spec.ts b/frontend/tests/e2e/email-templates.spec.ts index 2566b6f..9718670 100644 --- a/frontend/tests/e2e/email-templates.spec.ts +++ b/frontend/tests/e2e/email-templates.spec.ts @@ -1,44 +1,23 @@ -import { test, expect } from './fixtures'; - -const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; +import { test, expect, uniqueSuffix } from './fixtures'; // Each test leaves the templates as it found them, because they are stored in // admin_settings and would otherwise change the copy a later test reads. -async function restore(page: import('@playwright/test').Page, key: string) { - await page.request.delete(`/api/admin/email-templates/${key}`); +async function restore(request: import('@playwright/test').APIRequestContext, key: string) { + await request.delete(`/api/admin/email-templates/${key}`); } -async function openEmails(page: import('@playwright/test').Page) { - await page.goto('/admin'); - await page.getByRole('tab', { name: 'Emails' }).click(); - // The rail's first entry, rather than a heading — the tab label is the - // heading now, so there is no second one inside the page to wait on. - await expect(page.getByRole('tab', { name: /Email verification/ })).toBeVisible(); -} - -// Opens one template's tab. Only the active tab's editor is mounted, which is -// what makes the labels below unambiguous — the previous stacked layout had -// every editor on screen at once and a locator for "Save" matched all six. -async function openTemplate(page: import('@playwright/test').Page, label: string) { - await page.getByRole('tab', { name: new RegExp(label) }).click(); - await expect(page.getByLabel(`${label} subject`)).toBeVisible(); -} - -const previewFrame = (page: import('@playwright/test').Page, label: string) => - page.frameLocator(`iframe[title="${label} preview"]`); - // Serial: these edit one shared stored template, and the suite runs fully // parallel by default — so run concurrently they would race, one asserting a // template is unset while another has just saved it. test.describe.configure({ mode: 'serial' }); test.describe('Editing the customer emails', () => { - test.afterEach(async ({ page }) => { - await restore(page, 'passwordReset'); + test.afterEach(async ({ page, admin, adminEmails }) => { + await restore(page.request, 'passwordReset'); }); - test('offers every template as a tab, marked default until it is edited', async ({ page }) => { - await openEmails(page); + test('offers every template as a tab, marked default until it is edited', async ({ page, admin, adminEmails }) => { + await admin.open('Emails'); for (const label of [ 'Email verification', @@ -48,25 +27,25 @@ test.describe('Editing the customer emails', () => { 'Cart reminder', 'Email address changed' ]) { - await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible(); + await expect(adminEmails.railTab(new RegExp(label))).toBeVisible(); } // Only a customised template is marked, so which ones have been changed is // visible without opening each one. An untouched template carries nothing. - await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible(); - await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0); + await expect(adminEmails.railTab(/Password reset/)).toBeVisible(); + await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0); }); - test('saves a replacement subject and body', async ({ page }) => { - const subject = `Reset ${suffix()}`; - await openEmails(page); - await openTemplate(page, 'Password reset'); + test('saves a replacement subject and body', async ({ page, admin, adminEmails }) => { + const subject = `Reset ${uniqueSuffix()}`; + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); - await page.getByLabel('Password reset subject').fill(subject); + await adminEmails.subject('Password reset').fill(subject); await page .getByLabel('Password reset body') .fill('Fresh wording. [Choose a new password]({{resetUrl}}).'); - await page.getByRole('button', { name: 'Save', exact: true }).click(); + await adminEmails.saveButton.click(); await expect(page.getByText('Password reset saved')).toBeVisible(); @@ -79,12 +58,12 @@ test.describe('Editing the customer emails', () => { // The assertion that matters. A body without its link still sends and still // looks fine in the log, so the save has to be refused rather than warned // about — and the admin has to be told which placeholder is missing. - test('refuses a body that drops the required placeholder, and says which', async ({ page }) => { - await openEmails(page); - await openTemplate(page, 'Password reset'); + test('refuses a body that drops the required placeholder, and says which', async ({ page, admin, adminEmails }) => { + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); - await page.getByLabel('Password reset body').fill('Just click the thing in your email.'); - await page.getByRole('button', { name: 'Save', exact: true }).click(); + await adminEmails.body('Password reset').fill('Just click the thing in your email.'); + await adminEmails.saveButton.click(); await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible(); @@ -94,17 +73,17 @@ test.describe('Editing the customer emails', () => { expect(reset.body).toBeNull(); }); - test('restores the built-in copy', async ({ page }) => { + test('restores the built-in copy', async ({ page, admin, adminEmails }) => { await page.request.put('/api/admin/email-templates/passwordReset', { data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' } }); - await openEmails(page); + await admin.open('Emails'); // Marked in the rail before it is opened, which is the whole point of the // dot — the stored template above was never touched through the UI. - await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible(); - await openTemplate(page, 'Password reset'); - await page.getByRole('button', { name: 'Restore default' }).click(); + await expect(adminEmails.customisedTab('Password reset')).toBeVisible(); + await adminEmails.openTemplate('Password reset'); + await adminEmails.restoreDefaultButton.click(); await expect(page.getByText('Password reset restored to the default')).toBeVisible(); @@ -116,14 +95,14 @@ test.describe('Editing the customer emails', () => { }); test.describe('Previewing the customer emails', () => { - test.afterEach(async ({ page }) => { - await restore(page, 'passwordReset'); + test.afterEach(async ({ page, admin, adminEmails }) => { + await restore(page.request, 'passwordReset'); }); - test('shows the draft being edited, not the stored copy', async ({ page }) => { - const wording = `Wording ${suffix()}`; - await openEmails(page); - await openTemplate(page, 'Password reset'); + test('shows the draft being edited, not the stored copy', async ({ page, admin, adminEmails }) => { + const wording = `Wording ${uniqueSuffix()}`; + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); await page .getByLabel('Password reset body') @@ -131,17 +110,17 @@ test.describe('Previewing the customer emails', () => { // Nothing has been saved. The preview still reflects it, which is the whole // point: an admin sees the effect before committing to it. - await expect(previewFrame(page, 'Password reset').getByText(wording)).toBeVisible(); + await expect(adminEmails.preview('Password reset').getByText(wording)).toBeVisible(); const stored = await (await page.request.get('/api/admin/email-templates')).json(); expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull(); }); - test('substitutes sample values rather than showing raw placeholders', async ({ page }) => { - await openEmails(page); - await openTemplate(page, 'Password reset'); + test('substitutes sample values rather than showing raw placeholders', async ({ page, admin, adminEmails }) => { + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); - const frame = previewFrame(page, 'Password reset'); + const frame = adminEmails.preview('Password reset'); await expect(frame.getByRole('link')).toHaveAttribute('href', /reset-password\?token=/); await expect(frame.locator('body')).not.toContainText('{{resetUrl}}'); }); @@ -149,26 +128,26 @@ test.describe('Previewing the customer emails', () => { // The control that stops an admin putting script into a customer's inbox is // markdown-it's html: false on the server. The preview has to show the same // thing the mailer emits, or it would be reassuring about the wrong output. - test('escapes raw HTML exactly as the mailer does', async ({ page }) => { - await openEmails(page); - await openTemplate(page, 'Password reset'); + test('escapes raw HTML exactly as the mailer does', async ({ page, admin, adminEmails }) => { + await admin.open('Emails'); + await adminEmails.openTemplate('Password reset'); await page .getByLabel('Password reset body') .fill(' [link]({{resetUrl}})'); - await expect(previewFrame(page, 'Password reset').getByText('')).toBeVisible(); + await expect(adminEmails.preview('Password reset').getByText('')).toBeVisible(); }); // Appended by the server and not editable, so it has to appear in the preview // of the two templates it belongs to and nowhere else. - test('includes the consent footer on a favorite template, and not on others', async ({ page }) => { - await openEmails(page); + test('includes the consent footer on a favorite template, and not on others', async ({ page, admin, adminEmails }) => { + await admin.open('Emails'); - await openTemplate(page, 'Favorited item sold'); - await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible(); + await adminEmails.openTemplate('Favorited item sold'); + await expect(adminEmails.preview('Favorited item sold').getByText(/account page/)).toBeVisible(); - await openTemplate(page, 'Password reset'); - await expect(previewFrame(page, 'Password reset').locator('body')).not.toContainText('account page'); + await adminEmails.openTemplate('Password reset'); + await expect(adminEmails.preview('Password reset').locator('body')).not.toContainText('account page'); }); }); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index e4ab922..17286e0 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -12,6 +12,10 @@ import { FilterDrawer } from './pages/FilterDrawer'; import { FavoritePrompt } from './pages/FavoritePrompt'; import { OrdersPage } from './pages/OrdersPage'; import { AdminInventory } from './pages/AdminInventory'; +import { AdminTaxonomy } from './pages/AdminTaxonomy'; +import { AdminCustomers } from './pages/AdminCustomers'; +import { AdminEmails } from './pages/AdminEmails'; +import { AdminSettings } from './pages/AdminSettings'; import { uniqueEmail } from './support/api'; // Re-exported so specs can import everything from here — expect, Page, @@ -29,6 +33,10 @@ export { FilterDrawer } from './pages/FilterDrawer'; export { FavoritePrompt } from './pages/FavoritePrompt'; export { OrdersPage } from './pages/OrdersPage'; export { AdminInventory } from './pages/AdminInventory'; +export { AdminTaxonomy } from './pages/AdminTaxonomy'; +export { AdminCustomers } from './pages/AdminCustomers'; +export { AdminEmails } from './pages/AdminEmails'; +export { AdminSettings } from './pages/AdminSettings'; const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output'); const collectingCoverage = process.env.COVERAGE === 'true'; @@ -58,6 +66,10 @@ interface Pages { favoritePrompt: FavoritePrompt; orders: OrdersPage; adminInventory: AdminInventory; + adminTaxonomy: AdminTaxonomy; + adminCustomers: AdminCustomers; + adminEmails: AdminEmails; + adminSettings: AdminSettings; } interface Data { @@ -123,6 +135,18 @@ export const test = base.extend({ adminInventory: async ({ page }, use) => { await use(new AdminInventory(page)); }, + adminTaxonomy: async ({ page }, use) => { + await use(new AdminTaxonomy(page)); + }, + adminCustomers: async ({ page }, use) => { + await use(new AdminCustomers(page)); + }, + adminEmails: async ({ page }, use) => { + await use(new AdminEmails(page)); + }, + adminSettings: async ({ page }, use) => { + await use(new AdminSettings(page)); + }, adminApi: async ({ playwright, baseURL }, use) => { const context = await playwright.request.newContext({ baseURL }); diff --git a/frontend/tests/e2e/pages/AdminCustomers.ts b/frontend/tests/e2e/pages/AdminCustomers.ts new file mode 100644 index 0000000..0af7b84 --- /dev/null +++ b/frontend/tests/e2e/pages/AdminCustomers.ts @@ -0,0 +1,53 @@ +import { Locator, Page } from '@playwright/test'; + +/** + * The Customers tab. + * + * Rows are found by email rather than position: the table is shared with every + * other run's accounts and is paginated, so an index means a different customer + * depending on what else exists. + */ +export class AdminCustomers { + readonly confirmDialog: Locator; + + constructor(private readonly page: Page) { + this.confirmDialog = page.getByRole('dialog'); + } + + row(email: string): Locator { + return this.page.getByRole('row').filter({ hasText: email }); + } + + /** The status chip in a customer's row — ACTIVE or DISABLED. */ + status(email: string, status: string): Locator { + return this.row(email).getByText(status); + } + + disableButton(email: string): Locator { + return this.row(email).getByRole('button', { name: 'Disable' }); + } + + /** The count of items this customer is holding, which opens the list. */ + reservedCountButton(email: string): Locator { + return this.row(email).getByRole('button', { name: /item/ }); + } + + get reservedItemsDialog(): Locator { + return this.page.getByRole('dialog', { name: /Items reserved by/ }); + } + + reEnableButton(email: string): Locator { + return this.row(email).getByRole('button', { name: 'Re-enable' }); + } + + /** Disabling asks for confirmation, so the button appears twice. */ + async disable(email: string): Promise { + await this.disableButton(email).click(); + await this.confirmDialog.getByRole('button', { name: 'Disable' }).click(); + } + + async reEnable(email: string): Promise { + await this.reEnableButton(email).click(); + await this.confirmDialog.getByRole('button', { name: 'Re-enable' }).click(); + } +} diff --git a/frontend/tests/e2e/pages/AdminEmails.ts b/frontend/tests/e2e/pages/AdminEmails.ts new file mode 100644 index 0000000..a032feb --- /dev/null +++ b/frontend/tests/e2e/pages/AdminEmails.ts @@ -0,0 +1,61 @@ +import { FrameLocator, Locator, Page, expect } from '@playwright/test'; + +/** + * The Emails tab: a vertical rail of template types and one editor at a time. + * + * Only the active template's editor is mounted, which is what keeps the labels + * below unambiguous — the previous stacked layout had every editor on screen at + * once and a locator for "Save" matched all six (#135). + * + * A customised template is marked in the rail with a dot carrying an + * aria-label, so the state is in the tab's accessible name rather than colour + * alone — which is what lets a test assert it without opening the editor. + */ +export class AdminEmails { + readonly saveButton: Locator; + readonly restoreDefaultButton: Locator; + + constructor(private readonly page: Page) { + this.saveButton = page.getByRole('button', { name: 'Save', exact: true }); + this.restoreDefaultButton = page.getByRole('button', { name: 'Restore default' }); + } + + /** One template's entry in the rail. */ + railTab(label: string | RegExp): Locator { + return this.page.getByRole('tab', { name: label }); + } + + customisedTab(label: string): Locator { + return this.page.getByRole('tab', { name: new RegExp(`${label}.*Customised`) }); + } + + subject(label: string): Locator { + return this.page.getByLabel(`${label} subject`); + } + + body(label: string): Locator { + return this.page.getByLabel(`${label} body`); + } + + /** + * The rendered preview, which is an iframe. + * + * It is the server's rendering of the actual email rather than the markdown + * editor's own, so it is the only thing that shows the consent footer and the + * substituted placeholders. + */ + preview(label: string): FrameLocator { + return this.page.frameLocator(`iframe[title="${label} preview"]`); + } + + /** + * Opens one template and waits for its editor. + * + * The wait is the action's contract: the rail swaps the editor, and a locator + * resolved mid-swap finds the outgoing one. + */ + async openTemplate(label: string): Promise { + await this.railTab(new RegExp(label)).click(); + await expect(this.subject(label)).toBeVisible(); + } +} diff --git a/frontend/tests/e2e/pages/AdminInventory.ts b/frontend/tests/e2e/pages/AdminInventory.ts index eafe8a1..fba88bd 100644 --- a/frontend/tests/e2e/pages/AdminInventory.ts +++ b/frontend/tests/e2e/pages/AdminInventory.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; /** * The admin inventory tab: the item table and the form that adds to it. @@ -13,6 +13,13 @@ export class AdminInventory { readonly price: Locator; readonly category: Locator; readonly saveButton: Locator; + /** + * The item form is an antd Modal, whose footer button is "OK" rather than + * anything named for what it does. Named here so a spec says what it means. + */ + readonly confirmButton: Locator; + readonly formDialog: Locator; + readonly tagPicker: Locator; constructor(private readonly page: Page) { this.addItemButton = page.getByRole('button', { name: 'Add Item' }); @@ -20,6 +27,9 @@ export class AdminInventory { this.price = page.getByLabel('Price (USD)'); this.category = page.getByLabel('Category', { exact: true }); this.saveButton = page.getByRole('button', { name: 'Save', exact: true }); + this.confirmButton = page.getByRole('button', { name: 'OK' }); + this.formDialog = page.getByRole('dialog'); + this.tagPicker = page.getByText('Pick existing tags'); } row(itemName: string): Locator { @@ -53,7 +63,112 @@ export class AdminInventory { await this.page.getByRole('button', { name: itemName }).click(); } + deleteButton(itemName: string): Locator { + return this.row(itemName).getByRole('button', { name: 'Delete' }); + } + + /** The category field inside the form, and the inline-create controls in its popup. */ + get categoryField(): Locator { + return this.formDialog.getByLabel('Category', { exact: true }); + } + + get newCategoryName(): Locator { + return this.page.getByPlaceholder('New category name'); + } + + get createCategoryButton(): Locator { + return this.page.getByRole('button', { name: 'Create category' }); + } + + // ---- The inventory filter bar ---- + + get categoryFilter(): Locator { + return this.page.getByRole('combobox', { name: 'Filter by category' }); + } + + get statusFilter(): Locator { + return this.page.getByRole('combobox', { name: 'Filter by status' }); + } + + get minimumPrice(): Locator { + return this.page.getByLabel('Minimum price'); + } + + get maximumPrice(): Locator { + return this.page.getByLabel('Maximum price'); + } + + get clearFiltersButton(): Locator { + return this.page.getByRole('button', { name: 'Clear filters' }); + } + + /** + * Toggles one status in the multi-select. Clicking a selected option removes + * it, which is what the clearing test relies on. + * + * Two antd details decide this locator. It renders an invisible role="listbox" + * shim beside the real list for accessibility, so getByRole('option') finds + * something zero-sized that cannot be clicked. And once a status is selected + * it also renders as a tag carrying the same title as the option, so an + * unscoped getByTitle becomes ambiguous. Matching the visible option class + * avoids both. + * + * The dropdown is opened only when it is not already open: antd keeps it open + * after a selection in multiple mode, so clicking the box again would close it. + */ + async toggleStatus(label: string): Promise { + const option = this.page.locator(`.ant-select-item-option[title="${label}"]`); + if (!(await option.isVisible().catch(() => false))) { + await this.statusFilter.click(); + } + await option.click(); + } + + /** + * Narrows the table to one category. + * + * The table paginates and other specs create items concurrently, so an + * unfiltered page 1 is not a reliable place to look for a fixture. Waiting for + * a known row is the action's contract — the filter has been applied when the + * table has re-rendered under it. + */ + async filterByCategory(categoryName: string, expectedRow: string): Promise { + await this.categoryFilter.click(); + await this.categoryFilter.fill(categoryName); + await this.page.getByTitle(categoryName, { exact: true }).click(); + await expect(this.row(expectedRow)).toBeVisible(); + } + async openItemForm(): Promise { await this.addItemButton.click(); } + + /** + * Opens the form and waits for the data it fetches on open. + * + * Opening fetches both categories and tags, and each re-renders the modal as + * it lands. Waiting for only one still leaves the second to reflow the popup + * mid-interaction, so anything touching the category field has to wait for + * both rather than racing them. + */ + async openItemFormLoaded(): Promise { + const loaded = Promise.all([ + this.page.waitForResponse( + (res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET' + ), + this.page.waitForResponse( + (res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET' + ) + ]); + await this.addItemButton.click(); + await loaded; + } + + /** Fills the item form and submits it. */ + async addItem(name: string, price: string): Promise { + await this.openItemForm(); + await this.name.fill(name); + await this.price.fill(price); + await this.confirmButton.click(); + } } diff --git a/frontend/tests/e2e/pages/AdminPage.ts b/frontend/tests/e2e/pages/AdminPage.ts index 06f8db6..3500c3d 100644 --- a/frontend/tests/e2e/pages/AdminPage.ts +++ b/frontend/tests/e2e/pages/AdminPage.ts @@ -24,19 +24,38 @@ export class AdminPage { readonly tabList: Locator; readonly activePanel: Locator; readonly activeTabLabel: Locator; + readonly themeToggle: Locator; + readonly themedBody: Locator; + /** + * antd renders Select popups into a portal at the end of , outside the + * panel they belong to — so a dropdown cannot be found by scoping to the tab. + */ + readonly selectDropdown: 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(); + this.themeToggle = page.getByRole('switch'); + this.themedBody = page.locator('body'); + this.selectDropdown = page.locator('.ant-select-dropdown').first(); } async goto(): Promise { await this.page.goto('/admin'); } - tab(name: AdminTab | RegExp): Locator { - return this.page.getByRole('tab', { name }); + /** + * A top-level admin tab. + * + * `exact` matters on the Emails tab: the template rail inside it also renders + * tabs, and "Email verification" contains "Email". Exact matching keeps the + * shell's tabs and the rail's apart. + */ + tab(name: AdminTab | RegExp, exact = false): Locator { + return typeof name === 'string' + ? this.page.getByRole('tab', { name, exact }) + : this.page.getByRole('tab', { name }); } /** @@ -50,9 +69,41 @@ export class AdminPage { await this.openTab(name); } + /** The tree or table inside whichever panel is showing. */ + get activeTree(): Locator { + return this.activePanel.locator('.ant-tree').first(); + } + + get activeTable(): Locator { + return this.activePanel.locator('.ant-table').first(); + } + + /** + * Puts the admin in dark mode, whatever it was in before. + * + * Idempotent rather than a toggle: the theme persists across reloads, so a + * blind click leaves the state depending on what the previous test chose. + */ + async switchToDark(): Promise { + await this.goto(); + if ((await this.themeToggle.getAttribute('aria-checked')) !== 'true') { + await this.themeToggle.click(); + } + await expect(this.themedBody).toHaveAttribute('data-theme', 'dark'); + } + + /** The computed background of an element, for the contrast assertions. */ + static async backgroundOf(locator: Locator): Promise { + return locator.evaluate((el) => getComputedStyle(el).backgroundColor); + } + + static async colorOf(locator: Locator): Promise { + return locator.evaluate((el) => getComputedStyle(el).color); + } + /** Switches tabs without renavigating, for a test that visits two of them. */ async openTab(name: AdminTab): Promise { - await this.tab(name).click(); + await this.tab(name, true).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/AdminSettings.ts b/frontend/tests/e2e/pages/AdminSettings.ts new file mode 100644 index 0000000..2c5f709 --- /dev/null +++ b/frontend/tests/e2e/pages/AdminSettings.ts @@ -0,0 +1,36 @@ +import { Locator, Page, expect } from '@playwright/test'; + +/** + * The Settings tab: cart expiry, the two link lifetimes, and the greeting. + * + * The lifetimes are not cosmetic — the verification and password reset emails + * state their own duration through a placeholder rendered from these values, so + * changing one here changes what a customer is told (#136). The greeting is a + * format plus a separate fallback, because a format with the name edited out + * would ship "Hi ," to everyone who registered while first names were optional. + */ +export class AdminSettings { + readonly heading: Locator; + readonly cartExpiryHours: Locator; + readonly verifyTokenHours: Locator; + readonly passwordResetHours: Locator; + readonly greetingFormat: Locator; + readonly greetingFallback: Locator; + readonly saveButton: Locator; + + constructor(private readonly page: Page) { + this.heading = page.getByRole('heading', { name: 'Link lifetimes' }); + this.cartExpiryHours = page.getByLabel('Cart expiry (hours)'); + this.verifyTokenHours = page.getByLabel('Email verification link (hours)'); + this.passwordResetHours = page.getByLabel('Password reset link (hours)'); + this.greetingFormat = page.getByLabel('Greeting format'); + this.greetingFallback = page.getByLabel('Fallback, when there is no first name'); + this.saveButton = page.getByRole('button', { name: 'Save' }); + } + + async open(): Promise { + await this.page.goto('/admin'); + await this.page.getByRole('tab', { name: 'Settings', exact: true }).click(); + await expect(this.heading).toBeVisible(); + } +} diff --git a/frontend/tests/e2e/pages/AdminTaxonomy.ts b/frontend/tests/e2e/pages/AdminTaxonomy.ts new file mode 100644 index 0000000..d729ac4 --- /dev/null +++ b/frontend/tests/e2e/pages/AdminTaxonomy.ts @@ -0,0 +1,49 @@ +import { Locator, Page } from '@playwright/test'; + +/** + * The Categories and Tags tabs, which share a shape: a table or tree, an + * "Add X" button, and an antd Modal with a single Name field behind it. + * + * Categories are a tree because they nest arbitrarily deep; tags are flat. The + * tree is what makes "Add child" meaningful, and it is mounted before a new + * branch exists — so a child is only visible if expansion follows newly created + * nodes rather than the state captured at first render, which is the thing the + * spec here pins down. + */ +export class AdminTaxonomy { + readonly addCategoryButton: Locator; + readonly addTagButton: Locator; + readonly name: Locator; + readonly confirmButton: Locator; + readonly tagAddedNotice: Locator; + + constructor(private readonly page: Page) { + this.addCategoryButton = page.getByRole('button', { name: 'Add Category' }); + this.addTagButton = page.getByRole('button', { name: 'Add Tag' }); + this.name = page.getByLabel('Name'); + this.confirmButton = page.getByRole('button', { name: 'OK' }); + this.tagAddedNotice = page.getByText('Tag added'); + } + + categoryNode(name: string): Locator { + return this.page.getByRole('treeitem').filter({ hasText: name }); + } + + async addCategory(name: string): Promise { + await this.addCategoryButton.click(); + await this.name.fill(name); + await this.confirmButton.click(); + } + + async addChildCategory(parentName: string, childName: string): Promise { + await this.categoryNode(parentName).getByRole('button', { name: 'Add child' }).click(); + await this.name.fill(childName); + await this.confirmButton.click(); + } + + async addTag(name: string): Promise { + await this.addTagButton.click(); + await this.name.fill(name); + await this.confirmButton.click(); + } +}