A complete loop for defining, generating, running and gating tests: - qa/: scenario-card template + examples (API/UI/binding), the Claude generation prompt, and a QA runbook tying it together - e2e/CLAUDE.md: conventions that keep AI-generated tests consistent - run-tests.ps1: one runner for every layer (backend/api/web/ui/binding), logs per run under .testlogs/, JUnit output, evidence report, and a re-seed guard against stale demo data - .githooks/ + scripts/install-hooks.ps1: pre-commit secret scan and pre-push unit tests (shift-left before CI) - evidence report now captures each API call's request, payload and response (Authorization redacted); JUnit reporter added for CI - scripts/db.mjs: db:check freshness guard Azure DevOps pipeline deferred; the runner already emits JUnit + the evidence report for any CI to publish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DEMO APPLICATION FOR QA AUTOMATION TravelHub
A multi-tenant flight booking application: Angular frontend, Go backend, PostgreSQL database. Built as a reference sample for travel-domain SaaS, where several travel agencies ("tenants") share one deployment but never see each other's inventory, customers, or revenue.
Authentication is HTTP Basic auth, as requested, scoped per tenant.
Prerequisites
| Tool | Version used here |
|---|---|
| Go | 1.27 |
| Node.js | 24 LTS |
| PostgreSQL | 17 |
Quick start
1. Configure the database connection
cd backend
cp .env.example .env # then edit .env with your PostgreSQL credentials
2. Create the database and load the schema
createdb -U postgres travelhub
psql -U postgres -d travelhub -f migrations/001_init.sql
psql -U postgres -d travelhub -f migrations/002_seed.sql
psql -U postgres -d travelhub -f migrations/003_demo_bookings.sql
All three are safe to re-run: 002 skips rows that already exist, and 003
returns early once its bookings are present, so seats are never
double-decremented.
On Windows, createdb and psql live in C:\Program Files\PostgreSQL\17\bin.
3. Run the API (listens on http://localhost:8080)
cd backend
go run ./cmd/api
4. Run the frontend (serves http://localhost:4200)
cd frontend
npm install
npm start
Open http://localhost:4200 and sign in with any demo account below.
Demo accounts
Every seeded account uses the password demo1234.
| Tenant | Role | |
|---|---|---|
| Skyline Travel | admin@skyline.test |
admin |
| Skyline Travel | agent@skyline.test |
agent |
| Globetrotter Tours | admin@globetrotter.test |
admin |
| Globetrotter Tours | agent@globetrotter.test |
agent |
Sign in to each tenant in turn and search JFK → LHR: both agencies sell the
same routes, but Globetrotter's fares carry a 12% negotiated markup and
availability differs. That contrast is the point of the sample — it makes
tenant isolation visible rather than theoretical.
How multi-tenancy works
Storage model: shared database, shared schema, tenant_id discriminator.
Every tenant-owned table (users, flights, bookings, passengers) carries
a tenant_id foreign key. Reference data that is genuinely global — airports
and airlines — does not.
This model was chosen over schema-per-tenant or database-per-tenant because it keeps migrations single-shot and connection pooling simple, which is the right trade for a sample and for most early-stage travel SaaS. The cost is that isolation is enforced in application code rather than by the database, so the codebase holds that line in three specific places:
- The tenant is resolved from credentials, not from client input. The
X-Tenantheader (or subdomain) names an agency, but the password is then verified against the user row inside that tenant. Credentials valid at one agency return 401 at another. - Handlers never read a tenant id from the request. They take it from the
authenticated identity in the request context. See the package comment in
internal/handlers. - Every tenant-scoped query filters on
tenant_idas$1. This is a stated convention ininternal/repository, and a lookup that misses returnsErrNotFound→ HTTP 404, so probing for another tenant's record is indistinguishable from asking for one that does not exist.
If you take this to production, add PostgreSQL row-level security as a
second line of defence, so a missed WHERE clause fails closed at the database
rather than leaking rows.
Authentication
Basic auth, per the brief. Credentials travel in the standard
Authorization: Basic base64(email:password) header alongside X-Tenant.
Passwords are stored as bcrypt hashes (seeded via pgcrypto's
crypt()/gen_salt('bf'), which golang.org/x/crypto/bcrypt verifies directly).
Two deliberate details:
- 401 responses omit
WWW-Authenticate. Sending it would make the browser open its own credential dialog, which would fight with the Angular sign-in screen. - A missing user still pays for a bcrypt comparison, so response timing does not reveal which email addresses are registered.
Production note: Basic auth sends re-usable credentials on every request. It is fine behind TLS for an internal tool or a sample, but a public deployment wants short-lived tokens (JWT or session cookies) and a refresh flow. The auth logic is isolated in
internal/middlewareandinternal/auth, so swapping it touches two files and no handler.
API
All endpoints require Authorization and X-Tenant headers except the two
marked public.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
Liveness check (public) |
| GET | /api/tenants |
Agency list for the sign-in picker (public) |
| GET | /api/session |
Current user and tenant; validates sign-in |
| GET | /api/flights |
Search inventory |
| GET | /api/flights/{id} |
One flight |
| GET | /api/airports |
Airports in this tenant's network |
| GET | /api/airlines |
Carriers in this tenant's inventory |
| GET | /api/bookings |
List bookings (?status=confirmed) |
| POST | /api/bookings |
Create a booking |
| GET | /api/bookings/{id} |
One booking with passengers |
| POST | /api/bookings/{id}/cancel |
Cancel and return seats to inventory |
| GET | /api/dashboard |
Agency totals, top routes, recent bookings |
Flight search parameters: origin, destination (IATA codes), date
(YYYY-MM-DD), cabin (economy, premium_economy, business, first),
passengers, sort (price, duration, departure), limit, offset.
Example:
curl -u agent@skyline.test:demo1234 -H 'X-Tenant: skyline' \
'http://localhost:8080/api/flights?origin=JFK&destination=LHR&sort=price&limit=5'
Overselling
POST /api/bookings runs in one transaction and takes a row lock on the flight
(SELECT ... FOR UPDATE) before reading availability, so two agents booking the
last seats concurrently cannot oversell — the second waits and then sees the
decremented count. Cancelling restores the seats and is guarded against
double-crediting by filtering on status = 'confirmed' in the UPDATE.
Project layout
TS_Proto/
├── backend/
│ ├── cmd/api/main.go server entrypoint, routes, shutdown
│ ├── internal/
│ │ ├── auth/ identity in request context
│ │ ├── config/ .env loading, DSN assembly
│ │ ├── database/ pgx pool
│ │ ├── handlers/ HTTP layer
│ │ ├── middleware/ basic auth, tenant resolution, CORS
│ │ ├── models/ API types
│ │ └── repository/ all SQL
│ ├── migrations/
│ │ ├── 001_init.sql schema
│ │ ├── 002_seed.sql demo tenants, users, routes, inventory
│ │ └── 003_demo_bookings.sql demo reservations, so the dashboard is alive
│ ├── internal/testsupport/ per-test tenant harness for the Go suite
│ └── .env local credentials (git-ignored)
├── frontend/ Angular application (*.spec.ts alongside source)
├── e2e/ Playwright coded UI tests
│ ├── pages/ one page object per screen
│ ├── fixtures/ shared fixtures and the console-error guard
│ └── tests/ the specs
├── scripts/db.mjs create / migrate / reset the database
└── .github/workflows/ci.yml runs all three suites
Testing
Three suites — 311 tests in total. TESTING.md explains how they
are structured; TEST-CATALOG.md lists every test by name
and is regenerated with npm run docs:tests.
npm run setup # one-time: dependencies and a browser
npm run db:test # one-time: the empty database the Go tests use
npm run test # everything
npm run test:unit # Go + Angular only, no browser
npm run docs:tests # regenerate TEST-CATALOG.md
| Suite | Count | What it proves |
|---|---|---|
| Go | 115 | Tenant isolation, seat accounting and validation against a real PostgreSQL |
| Angular | 114 | Services, guards, the auth interceptor, and component behaviour |
| Playwright | 82 | The whole stack in a real browser, plus 19 simulated-condition tests covering failing, slow and offline backends |
CI runs all three on every push — see .github/workflows/ci.yml.
Known limitations
This is a sample, and these are deliberate omissions rather than oversights:
- No migration runner — migrations are applied with
psql(ornpm run db:migrate). - Round trips, seat maps, ancillaries, payment capture, and ticketing are all out of scope; a booking is a single one-way flight.
- Tenant and user administration is seed-data only; there is no signup or invite flow.