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>
This commit is contained in:
+391
@@ -0,0 +1,391 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user