**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
101 lines
4.3 KiB
JavaScript
101 lines
4.3 KiB
JavaScript
import { expect } from '@playwright/test'
|
|
|
|
// Matched to e2e/seed.py, which prints them when it runs. Overridable the same
|
|
// way, for a stack somebody wants to leave up and poke at.
|
|
export const EDUCATOR = {
|
|
email: process.env.E2E_EDUCATOR_EMAIL || 'educator@e2e.example.com',
|
|
password: process.env.E2E_EDUCATOR_PASSWORD || 'e2e-educator-password',
|
|
}
|
|
export const LEARNER = {
|
|
email: process.env.E2E_LEARNER_EMAIL || 'learner@e2e.example.com',
|
|
password: process.env.E2E_LEARNER_PASSWORD || 'e2e-learner-password',
|
|
}
|
|
|
|
/**
|
|
* A learner of this worker's own.
|
|
*
|
|
* The seed makes one per parallel worker. They share a database, and a session
|
|
* remembers where it was left — so two workers sitting the same session as the
|
|
* same person tread on each other, and the failure looks like a product bug
|
|
* rather than a fixture one. Educators can share: nothing they do here writes
|
|
* per-person state.
|
|
*/
|
|
export function learnerFor(testInfo) {
|
|
const index = (testInfo?.parallelIndex ?? 0) % 4
|
|
return { ...LEARNER, email: LEARNER.email.replace('@', `+w${index}@`) }
|
|
}
|
|
|
|
// One sign-in per account per worker. The limiter allows ten attempts from an
|
|
// address in fifteen minutes, and the whole suite arrives from one address — so
|
|
// a test that signs in afresh each time spends the budget and the rest of the
|
|
// run fails on 429s that have nothing to do with what is being tested.
|
|
const tokens = new Map()
|
|
|
|
export async function tokenFor(request, who) {
|
|
if (!tokens.has(who.email)) {
|
|
const response = await request.post('/api/v1/auth/login', { data: who })
|
|
expect(response.ok(), `sign-in failed: ${await response.text()}`).toBeTruthy()
|
|
tokens.set(who.email, (await response.json()).access_token)
|
|
}
|
|
return tokens.get(who.email)
|
|
}
|
|
|
|
/**
|
|
* Sign in and hand the browser the token.
|
|
*
|
|
* Not through the form: every test would then be a test of the login form, and
|
|
* when that breaks the whole suite goes red at once instead of one test. The
|
|
* form has its own test, which does use the form.
|
|
*/
|
|
export async function signIn(page, who = LEARNER) {
|
|
const token = await tokenFor(page.request, who)
|
|
// The origin has to exist before localStorage does.
|
|
await page.goto('/')
|
|
await page.evaluate(value => localStorage.setItem('token', value), token)
|
|
return token
|
|
}
|
|
|
|
/** Past the "what are you studying for?" gate, which every page shows first. */
|
|
export async function chooseObjective(page) {
|
|
const picker = page.getByRole('heading', { name: /what are you studying for/i })
|
|
if (await picker.isVisible().catch(() => false)) {
|
|
await page.getByText(/Pediatrics Boards|Respiratory|E2E/).first().click()
|
|
await expect(picker).toBeHidden()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a session and be sitting it, however the player decides to start.
|
|
*
|
|
* A saved session shows an overview first; one opened with nothing saved goes
|
|
* straight in. Both are correct, and a test that insists on the button is
|
|
* testing which of the two happened rather than the session itself.
|
|
*/
|
|
export async function startSession(page, id = 1) {
|
|
// `restart=1` every time. These tests share one account, and a session
|
|
// remembers where it was left — so without this the second test to run finds
|
|
// itself on question three of a session the first one was half-way through.
|
|
await page.goto(`/study/${id}?restart=1`)
|
|
await chooseObjective(page)
|
|
const start = page.getByRole('button', { name: /start session/i })
|
|
if (await start.isVisible().catch(() => false)) await start.click()
|
|
await expect(page.locator('.question-card').first()).toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Nothing on the page may be reachable only by scrolling sideways.
|
|
*
|
|
* Polled rather than sampled once. A page mid-layout — a figure that has not
|
|
* finished loading, a font still swapping — is momentarily wider than the
|
|
* window and then is not, and a single measurement catches whichever moment it
|
|
* happened to land in. A page that really does overflow stays wide, so it
|
|
* still fails, just a second later.
|
|
*/
|
|
export async function expectNoHorizontalOverflow(page) {
|
|
await page.evaluate(() => document.fonts?.ready).catch(() => {})
|
|
await expect.poll(() => page.evaluate(() => {
|
|
const doc = document.documentElement
|
|
// One pixel of slack for sub-pixel rounding on a scaled device.
|
|
return doc.scrollWidth - doc.clientWidth <= 1
|
|
}), { message: 'the page scrolls sideways', timeout: 5000 }).toBe(true)
|
|
}
|