Files
redefined-designs/frontend/tests/e2e/favorites.spec.ts
T
bermudalamb 332c1e7cd0 feat(ci): import test coverage into SonarQube (#61)
SonarQube reported 0% coverage for 78 unit, 134 integration and 83 end-to-end tests, so the coverage-on-new-code gate — the most useful thing SonarQube offers a project this size — has been failing permanently while looking configured. It now reports 69.6%, verified by a real scan.

Backend coverage comes from both suites, written to separate directories because jest writes coverage/lcov.info by default and the second run would silently overwrite the first. Both are needed rather than just the fast one: the unit suite alone reports 11%, because everything in src/routes is exercised by the integration suite. That suite is manual-only after hanging for 3h12m post-run, so it runs here with --forceExit and the job carries a hard timeout; jest confirmed during testing that it would otherwise have hung.

The frontend had no unit tests at all, so its coverage comes from Playwright driving an istanbul-instrumented dev server, collected per test by an auto-fixture and merged with nyc. The 17 specs now import from a local fixtures module that re-exports @playwright/test, which is what lets the fixture attach without touching each test body.

Instrumentation is gated behind COVERAGE=true and loaded by dynamic import, since vite-plugin-istanbul is ESM-only while vite.config.ts evaluates as CommonJS. Both directions were checked rather than assumed: a normal build contains no instrumentation, and the dev server instruments nested modules as well as top-level ones — the first attempt used an include glob of src/* which would have silently missed everything under src/admin and src/cart.

coverage:report fails when nothing was collected instead of writing an empty report, and that guard was fired deliberately to confirm it works. This project has been bitten twice by tools succeeding while measuring nothing — SonarQube skipping the whole frontend and still exiting EXECUTION SUCCESS in #67, and an ESLint matcher silently matching no files during #60 — and coverage has exactly that shape: an uninstrumented dev server lets every test pass while gathering nothing, and the 0% that follows reads as lost coverage rather than broken collection.

Worth knowing when reading the numbers: end-to-end coverage flatters. Istanbul marks a line covered when the browser ran it, so a component rendered during a test counts as covered with nothing asserting anything about it. Recorded in the design doc and the project context rather than left to be discovered.

Also declares sonar.tests so test files are analysed under the test rule set rather than as production code.

Closes #61
2026-08-20 10:13:13 -05:00

149 lines
6.5 KiB
TypeScript

import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const ITEM = `Favoritable ${RUN}`;
const uniqueEmail = () => `fav-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const res = await api.post('/api/admin/items', {
multipart: { name: ITEM, description: '', price: '60', category_id: '', tags: '[]' }
});
expect(res.ok()).toBeTruthy();
await api.dispose();
});
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
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 });
}
// Going to the storefront remounts the app, so the session is briefly still
// resolving. The heart deliberately ignores clicks in that window rather than
// wrongly prompting a signed-in customer to sign in, so wait for the header to
// show the account link — which is exactly what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
// The storefront paginates as items accumulate, so find the card by name.
function heart(page: Page, itemName: string) {
return page.getByRole('button', { name: new RegExp(`(Add|Remove) ${itemName}`) });
}
test.describe('Favoriting items', () => {
test('a signed-out visitor is prompted to sign in, and the favorite completes', async ({ page }) => {
await page.goto('/');
await heart(page, ITEM).first().click();
// Same inline prompt Add to Cart already uses.
await expect(page.getByRole('dialog')).toBeVisible();
const email = uniqueEmail();
await page.getByRole('dialog').getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('dialog').getByLabel('Password').fill(PASSWORD);
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click();
// The favorite the visitor originally asked for is applied on success.
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
});
test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
const prompt = page.getByRole('dialog');
await expect(prompt).toBeVisible();
await expect(prompt).toContainText('Want to know if this sells?');
// The reason for asking has to be given, not just the ask.
await expect(prompt).toContainText(/one of a kind/i);
await expect(prompt).toContainText(/separate from any marketing/i);
await prompt.getByRole('button', { name: 'Yes, email me' }).click();
await expect(page.getByText('We will let you know')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(true);
// Accepting item alerts must not sign anyone up for marketing.
expect(me.marketing_consent).toBe(false);
});
test('declining the opt-in still keeps the favorite', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toHaveLength(1);
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(false);
});
test('unfavoriting removes it', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
await page.getByRole('button', { name: `Remove ${ITEM} from favorites` }).click();
await expect(page.getByRole('button', { name: `Add ${ITEM} to favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toEqual([]);
});
test('favorites survive a reload', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
// Proves it was stored server-side rather than held in component state.
await page.reload();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
});
test('the account page can turn the alerts off again', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
await page.goto('/account');
// Scoped to the account modal. The storefront renders behind it and has a
// theme switch of its own, so an unscoped switch locator only picks the
// right control by DOM accident.
const account = page.getByRole('dialog', { name: 'My Account' });
await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible();
await account.getByRole('switch').last().click();
await expect(page.getByText('Turned off')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(false);
});
});