Files
TS_Proto/TESTING.md
T
nilesh.pandClaude Opus 4.8 4d3eb18a6c Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 11:38:01 +05:30

392 lines
17 KiB
Markdown

# 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/<dir>/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 `<details>` 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.