A new developer needs only Docker Desktop — no local Go, Node, or PostgreSQL. - docker-compose.yml: PostgreSQL + Go API + Angular (nginx) with healthcheck, migrations auto-applied via docker-entrypoint-initdb.d, data in a volume - backend/Dockerfile: static Go binary on distroless (nonroot) - frontend/Dockerfile: Angular prod build served by nginx, which proxies /api to the api service (same origin, no CORS) - docker.ps1: setup / start / stop / restart / reset / rebuild / status / logs; reads DB creds from backend/.env (single source, nothing duplicated) - .dockerignore keeps .env and local artifacts out of image build contexts - README: "Run with Docker" quick start Not yet run end to end here (Docker is not installed on this machine); the compose and script are validated for syntax and structure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
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.
Documentation
- Product wiki — overview, domain model, features, roles & access, API reference, glossary
- CLAUDE.md / AGENTS.md — guiding principles for developers and AI agents
- SECURITY.md · GOVERNANCE.md · CONTRIBUTING.md — the rules
- TESTING.md · qa/README.md — test suites and the AI-assisted QA workflow
Run with Docker (quickest — no local toolchain)
A new developer needs only Docker Desktop — no local Go, Node, or PostgreSQL. One script sets up, starts and stops the whole stack:
# one-time: set the DB password the containers will use
cp backend\.env.example backend\.env # then edit DB_PASSWORD
.\docker.ps1 setup # build images, start everything, seed the database
.\docker.ps1 stop # stop (keeps data)
.\docker.ps1 start # start again
.\docker.ps1 reset # wipe data and re-seed with fresh flight dates
.\docker.ps1 logs -Follow
Then open http://localhost:4200 (nginx serves the SPA and proxies /api to
the Go API on :8080). Sign in with any demo account, password demo1234.
The database is created and seeded on first setup via the migrations mounted
into PostgreSQL. Don't run the native app.ps1 at the same time — it uses the
same ports.
For local development without containers, use the native setup below.
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.