# Testing TravelHub Three suites, each answering a different question. | Suite | What it proves | Where | Count | | ----- | -------------- | ----- | ----- | | **Go** | The API behaves — including tenant isolation and seat accounting — against a real PostgreSQL | `backend/internal/**/*_test.go` | 115 | | **Angular** | Components, services, guards and the HTTP interceptor behave, in isolation | `frontend/src/**/*.spec.ts` | 114 | | **Playwright** | The whole stack works in a real browser, the way an agent uses it | `e2e/tests/*.spec.ts` | 82 | Every test is listed in **[TEST-CATALOG.md](TEST-CATALOG.md)**, generated from the sources by `npm run docs:tests`. CI fails if it is out of date, so it cannot drift. --- ## Running everything ```bash npm run test # Go, then Angular, then Playwright npm run test:unit # Go + Angular only — no browser, a few seconds ``` Individually: ```bash npm run test:go # go test ./... npm run test:go:race # with the race detector npm run test:go:cover # with a coverage report npm run test:web # Angular unit tests, one pass npm run test:web:watch # Angular unit tests, re-running on save npm run test:e2e # Playwright, headless npm run test:e2e:ui # Playwright's interactive runner npm run test:e2e:headed # watch the browser work npm run test:e2e:report # open the last HTML report npm run lint # go vet + typecheck the Playwright suite ``` ## First-time setup ```bash npm run setup # installs frontend and e2e dependencies, plus Chromium npm run db:test # creates the empty travelhub_test database Go tests use ``` The Go suite needs an empty database it can own; it applies the schema itself. Nothing else needs preparing. --- ## The Go suite **These are integration tests, not mocked unit tests.** They run against a real PostgreSQL, because the behaviour worth protecting here lives in SQL: the `tenant_id` filter on every query, the `SELECT ... FOR UPDATE` that prevents overselling, the `ON DELETE CASCADE` relationships. A mocked database would assert that the code calls the functions it calls, which proves nothing. ### Isolation between tests `internal/testsupport` gives **each test its own tenant**, created fresh and dropped afterwards. Because the entire application is tenant-scoped, a per-test tenant is a private world — no truncating tables between runs, no ordering constraints, and every query exercises the tenant scoping on the way past. ```go func TestSomething(t *testing.T) { f := testsupport.Setup(t) // new tenant, admin + agent users flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 10, Price: 250}) // ... f.Repo, f.Tenant, f.Agent, f.SeatsAvailable(t, flight.ID) } ``` Cross-tenant tests simply call `Setup` twice and confirm the two cannot see each other. ### Configuration Credentials come from `backend/.env`, so if the app runs, the tests run. To point elsewhere: ```bash TEST_DATABASE_URL=postgres://user:pass@host:5432/some_test_db go test ./... ``` ### What is covered | Package | Coverage | Notable cases | | ------- | -------- | ------------- | | `config` | 97% | DSN escaping for passwords containing `@ : / # %`, `.env` precedence and parent-directory discovery | | `repository` | 84% | Tenant isolation on every read and write, concurrent overselling, cancel idempotency, PNR uniqueness | | `handlers` | 83% | Status codes, payload validation, unknown-field rejection, cross-tenant 404s | | `middleware` | 81% | Basic auth accept and reject paths, subdomain fallback, the admin gate, CORS | Three tests are worth knowing about by name: - `TestCreateBookingDoesNotOversellUnderConcurrency` fires eight simultaneous bookings at a flight with one seat and asserts exactly one succeeds. Run under `-race` in CI. - `TestCancelBookingIsNotRepeatable` confirms a second cancel does not credit the seats a second time. - `TestLoadEscapesAwkwardPasswords` round-trips real-world passwords through the DSN builder and back out via the pgx parser. --- ## The Angular suite Vitest with jsdom, via `ng test`. Services and guards are tested directly; components are tested through `TestBed` with fake collaborators, asserting on both component state and rendered DOM. What is covered: - **`AuthService`** — credentials are verified against the chosen tenant *before* being stored, a rejected sign-in leaves no trace, stored credentials are re-validated on restore, and unreadable `localStorage` degrades to signed out rather than throwing. - **`authInterceptor`** — stamps `Authorization` and `X-Tenant` on API calls, leaves other requests alone, never overwrites a header a caller set deliberately, and signs out on a 401 to a stamped request but not an unstamped one (which would loop on a failed sign-in). - **`authGuard` / `guestGuard`** — redirects, and the `returnTo` round trip. - **`ApiService`** — query-string construction, including that empty filters are dropped while a zero offset is kept. - **Components** — pagination arithmetic, fare recalculation as travellers are added, seat-shortage warnings, error-message mapping by status code, and the `null`-vs-`[]` shape Go produces for empty slices. --- ## The Playwright suite Coded UI tests driving Chromium against the real Angular app and the real Go API. ``` e2e/ ├── playwright.config.ts projects, reporters, and the servers to start ├── fixtures/ │ ├── accounts.ts the seeded demo logins │ └── test.ts page-object fixtures + the console-error guard ├── pages/ one page object per screen └── tests/ the specs ``` ### It starts the stack for you `playwright.config.ts` declares both servers under `webServer`, so `npm run test:e2e` boots the Go API and the Angular dev server, waits for both to answer, and shuts them down afterwards. If they are already running locally it reuses them. ### Page objects, not selectors in tests Every screen has a page object exposing intent (`search.searchRoute('JFK', 'LHR')`, `bookingDetail.cancel(reference)`) rather than selectors. A markup change is then one edit, not twenty. Destructive actions name their target: `cancel(expectedReference)` asserts that booking is the one on screen before it clicks. These tests share a database with seeded demo data, and a mis-step earlier in a test must not be able to void somebody else's booking. ### Every test also asserts a clean console The `consoleErrors` fixture fails any test that produced an uncaught exception or a `console.error`. A stack trace in the console is a real defect even when the assertions happen to pass. The browser's own "Failed to load resource" notice for deliberate 401s and 404s is filtered out — it says nothing about whether the app handled the response. ### What is covered - **`auth.spec.ts`** — the agency picker, demo shortcuts, wrong passwords, valid credentials aimed at the wrong agency, session persistence across reloads, the `returnTo` round trip, and stale stored credentials being discarded. - **`search.spec.ts`** — filtering by route and cabin, party-size filtering, all three sort orders (asserted by reading the rendered fares and durations back and checking they are ordered), pagination, and the empty state. - **`booking.spec.ts`** — the full sale: fare carried into checkout, repricing as travellers change, validation, confirmation with a PNR, the booking appearing in the list, **seats leaving inventory and coming back on cancel**, and the two-step cancel being cancellable itself. - **`tenant-isolation.spec.ts`** — the same route priced differently per agency, one agency's booking invisible to the other *including by deep link*, per-agency revenue, and a deep link to another agency's flight being refused. - **`dashboard.spec.ts`** — headline figures, and a delta-based check that a sale moves revenue and seats sold, and cancelling moves them back. - **`responsive.spec.ts`** — no horizontal scrolling at 390px on any screen, wide tables scrolling inside their own container, and the sidebar collapsing. - **`simulation.spec.ts`** — the UI under simulated backend conditions; see below. ### Other simulation techniques available Playwright gives more than route interception, and these are worth reaching for rather than reinventing: | Technique | How | Good for | | --------- | --- | -------- | | **Record a test** | `npx playwright codegen http://localhost:4200` | Drafting a spec by clicking through the app; it emits selectors and assertions you then tidy into a page object | | **Replay a failure** | `npx playwright show-trace test-results//trace.zip` | A DOM snapshot, network log and console for every step of a failed run. Already configured: `trace: 'retain-on-failure'` | | **Device emulation** | `devices['Pixel 7']` in a project | Real viewport, user agent and touch behaviour. The `mobile-chrome` project already does this | | **Throttled network** | CDP `Network.emulateNetworkConditions` | Slow 3G, high latency. For per-request latency the `slow()` helper is simpler | | **Visual regression** | `await expect(page).toHaveScreenshot()` | Catching unintended layout drift. Not enabled here: snapshots are platform-specific, so CI needs its own baselines committed per OS | | **Accessibility audit** | `@axe-core/playwright` | Contrast, labelling and ARIA violations. Not installed; one dependency away | | **Clock control** | `await page.clock.setFixedTime(...)` | Freezing "today" so date-dependent screens are deterministic | ### The run report: evidence from what passed Playwright's own HTML report is a debugging tool — it keeps screenshots and traces only when something fails, which tells you nothing about what the suite actually proved. A second reporter fills that gap. ```bash npm run test:e2e # generates the report as part of every run npm run report # opens it from disk npm run report:serve # serves it at http://localhost:9400 instead ``` > **`npx playwright show-report` will not show this report.** That command > serves Playwright's own report from `e2e/playwright-report/`, which is a > different artifact for debugging failures. Run with no path argument from the > repo root and it binds port 9323 to a directory that does not exist there, so > every request returns 404 — a server that appears to be up but serves nothing. Output lands in `e2e/evidence-report/`: | File | Purpose | | ---- | ------- | | `index.html` | The report: a verdict, a summary by capability, then every scenario with its data and screenshots | | `report.md` | The same content as Markdown, for pasting into a ticket or a PR | | `report.json` | Machine readable, for a dashboard or a diff between runs | | `shots/` | The screenshots | It is structured as a document rather than a log: cover page with the verdict and run details, **§1 Summary** with a pass-rate table per area, then **§2 Detailed results** numbered `2.3.4` so a specific scenario can be cited in an email. Areas are named after what they cover ("Keeping agencies separate"), not after spec files. ### Exporting to PDF Three ways, all producing the same layout: ```bash npm run report:pdf # everything — about 99 pages, 11.5 MB npm run report:pdf:summary # summary + the narrated scenarios — 29 pages, 2.3 MB ``` Or open the report and press **Save as PDF** in the toolbar, which is just your browser's print dialog. The scripted export uses the Chromium that Playwright already installed, so there is no extra dependency and CI can attach a PDF to a build. Write it elsewhere with `--out`: ```bash cd e2e && node scripts/report-pdf.mjs --out ../release-evidence.pdf ``` Two details the export has to handle, and does: a collapsed `
` prints only its one-line summary, so everything is opened first; and lazily-loaded images never load off-screen because the PDF renderer does not scroll, so they are forced eager and awaited before printing. **Narrating a scenario.** The `evidence` fixture records a named moment — a screenshot plus whatever the test observed: ```ts await evidence.step('Seats removed from inventory', { flight: flightNumber, seatsBefore, seatsAfterSale, difference: seatsBefore - seatsAfterSale, }); ``` That becomes a titled entry in the report with a data table and the screenshot beneath it. `evidence.note(...)` records data with no screenshot, for values compared across pages; `evidence.fullPage(...)` captures the whole scrollable page instead of the viewport. The headline journeys are narrated this way — sign-in, route search and fare sorting, the full booking lifecycle, per-agency pricing, cross-tenant invisibility, and the dashboard before and after a sale. Every other test still contributes its end-of-test screenshot automatically (`screenshot: 'on'`), so the report covers the whole run; the toolbar filters to the narrated ones. A typical full run produces **96 screenshots across 82 scenarios**, about 14 MB. The directory is git-ignored and CI keeps it as an artifact for 30 days. ### Simulated-condition tests `simulation.spec.ts` is the odd one out: it intercepts `/api/**` in the browser and answers it directly, so the real backend is never called. This is for the states you cannot practically stage for real — a 500 from the database, a two-second response, a seat sold by somebody else in the half second between opening checkout and confirming, an agency with 98,432 flights, a passenger whose name is 200 characters long. Staging those against a live database means fixture gymnastics and flaky timing; intercepting takes a line. ```ts await simulateApi(page, { flights: fail(500, 'boom') }); await page.goto('/search'); await expect(page.locator('.alert-error')).toContainText('Could not load flights'); ``` `simulateApi` fills in a healthy default for every endpoint the scenario does not mention, so a test spells out only the one condition it is about. Three helpers cover the rest: | Helper | Meaning | | ------ | ------- | | `fail(status, message?)` | An error response in the API's own shape | | `slow(body, delayMs)` | A successful but slow response, for loading states | | `reply({status, body, delayMs})` | Anything else | | `{ offline: true }` | Abort every call, as though the network were gone | What it covers: failing and slow APIs on every screen, going offline mid-session, an expired session bouncing to sign-in and clearing storage, an empty agency, a brand-new agency whose `topRoutes` is `null`, the last seat flagged urgent, a 409 at the moment of sale, a party larger than the cabin, a 98,432-result page, 200-character names not widening the page, markup in data being escaped rather than rendered, and keyboard-only operation. These are the cheap ones: 19 tests in about 20 seconds, no database writes. They complement the real-stack specs rather than replacing them — those prove the system works, these prove the UI degrades well. > One trap worth knowing about. The reply envelope is branded with a symbol, > not recognised by shape. Domain objects here have a `status` field of their > own — a flight is `scheduled`, a booking is `confirmed` — so duck-typing on > `status` silently reads a booking as an HTTP response. That bug cost five > failing tests before it was found. ### Two deliberate constraints **Single worker.** These tests share one database and several assert on seat counts and booking totals. `workers: 1` keeps those assertions meaningful. The suite runs in about 90 seconds, so the parallelism is not missed. **The suite writes to your development database.** Tests cancel every booking they create and assert in deltas rather than absolutes, so they are safe to re-run and do not care what the database already held. If you would rather keep development data pristine, point the API at a scratch database before running: ```bash DB_NAME=travelhub_e2e npm run test:e2e ``` --- ## Continuous integration `.github/workflows/ci.yml` runs three jobs on every push and pull request: 1. **backend** — `go vet`, a `gofmt` check, then `go test -race` against a PostgreSQL 17 service container, with coverage uploaded as an artifact. 2. **frontend** — `npm ci`, unit tests, and a production build. 3. **e2e** — migrates a fresh database, installs Chromium, typechecks the suite, and runs Playwright. The HTML report and any failure traces are uploaded as artifacts and kept for 14 days. The e2e job waits for the other two, so a broken unit test fails fast without spending browser time. --- ## Adding tests **A new API endpoint** gets repository tests for the SQL (including a cross-tenant case that must return `ErrNotFound`) and handler tests for the status codes and validation. **A new screen** gets a page object in `e2e/pages/`, component tests for its logic, and at least one Playwright test for the path a user actually takes. **A bug fix** gets a test that fails before the fix. The two CSS bugs found while building this suite — a `1fr` grid track refusing to shrink below its table's min-content width, and a `text-transform: capitalize` applied to email addresses — are both now guarded, the first by `responsive.spec.ts` and the second by the shell component test. One rule worth keeping: **assert on behaviour the user can observe**, not on implementation. `expect(seatsAfter).toBe(seatsBefore - 2)` survives a refactor; asserting that a particular method was called does not.