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>
392 lines
17 KiB
Markdown
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.
|