pdf-quiz-generator/e2e/tests/mobile.spec.js
Daniel e72cdd6716 feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs
**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
2026-09-13 01:23:38 +02:00

96 lines
4.5 KiB
JavaScript

import { expect, test } from '@playwright/test'
import { chooseObjective, expectNoHorizontalOverflow, signIn, EDUCATOR } from './helpers.js'
/**
* The phone. Every bug in this file was found by a person on an iPhone and
* reported as a screenshot, which is a slow way to find out.
*/
test.describe('on a phone', () => {
// By width, not by `isMobile`: an iPad in landscape is a touch device with
// 1024px of room, where the reading rail is a rail and there is no drawer to
// open. The breakpoint here is the one in ArticlesPage.css.
test.skip(({ viewport }) => !viewport || viewport.width > 820,
'about the layout below 820px')
test.beforeEach(async ({ page }) => {
await signIn(page, EDUCATOR)
})
test('the menu opens the contents and the same button closes them', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
// Wait for the article, not just the page. The reader claims the menu
// button when it mounts, and it does not mount until the article arrives —
// tap in that window and you get the site menu, which is the right answer
// to "there is nothing else this button could mean yet".
await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i)
const burger = page.locator('.nav-burger')
await burger.click()
await expect(page.locator('.article-sections.open')).toBeVisible()
// Pressing it again is how a thumb closes a drawer. It used to do nothing,
// and the only way out was the strip of page beside it.
await burger.click()
await expect(page.locator('.article-sections.open')).toHaveCount(0)
})
test('the drawer clears the header rather than covering it', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i)
await page.locator('.nav-burger').click()
const header = await page.locator('.navbar').boundingBox()
const drawer = await page.locator('.article-sections').boundingBox()
expect(drawer.y, 'the drawer starts under the header').toBeGreaterThanOrEqual(header.height - 1)
// And the button that opened it is still the top thing at that point.
const onTop = await page.evaluate(() => {
const bar = document.querySelector('.nav-burger').getBoundingClientRect()
const el = document.elementFromPoint(bar.x + bar.width / 2, bar.y + bar.height / 2)
return el?.closest('.nav-burger') !== null
})
expect(onTop, 'something is covering the menu button').toBeTruthy()
})
test('a figure opens with the picture in it, not just its caption', async ({ page }) => {
// An SVG written with a viewBox and no width or height has a shape but no
// size; Safari resolves that to zero inside a shrink-to-fit box, and the
// viewer was a caption above an empty screen. The seed's figure is written
// the same way on purpose.
await page.goto('/articles/1')
await chooseObjective(page)
const thumb = page.locator('.imgfig-thumb').first()
await expect(thumb).toBeVisible()
await thumb.click()
const image = page.locator('.imgfig-frame img')
await expect(image).toBeVisible()
// Measured after it has actually loaded: an <img> that is still fetching
// is a box one line tall, which looks exactly like the bug being tested.
await expect.poll(() => image.evaluate(el => el.complete && el.naturalWidth > 0),
{ message: 'the figure never loaded' }).toBe(true)
const box = await image.boundingBox()
expect(box.width, 'the picture has no width').toBeGreaterThan(100)
expect(box.height, 'the picture has no height').toBeGreaterThan(40)
// And the caption is there too, which is all that used to be.
await expect(page.locator('.imgfig-desc')).toContainText(/stridor/i)
})
test('nothing on the reading page scrolls sideways', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
await expect(page.getByRole('heading', { name: 'Croup' }).first()).toBeVisible()
await expectNoHorizontalOverflow(page)
})
test('a text box does not zoom the page when it is tapped', async ({ page, browserName }) => {
test.skip(browserName !== 'webkit', 'the 16px rule is a Safari rule')
await page.goto('/ai')
await chooseObjective(page)
const size = await page.locator('textarea[aria-label="Ask AI Mode"]').evaluate(
el => parseFloat(getComputedStyle(el).fontSize))
// Under 16px, iOS zooms in on focus and never zooms back out.
expect(size).toBeGreaterThanOrEqual(16)
})
})