# 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](docs/wiki/README.md)** — overview, domain model, features, roles & access, API reference, glossary - **[CLAUDE.md](CLAUDE.md)** / **[AGENTS.md](AGENTS.md)** — guiding principles for developers and AI agents - **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)** · **[CONTRIBUTING.md](CONTRIBUTING.md)** — the rules - **[TESTING.md](TESTING.md)** · **[qa/README.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: ```powershell # 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** ```bash cd backend cp .env.example .env # then edit .env with your PostgreSQL credentials ``` **2. Create the database and load the schema** ```bash 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`) ```bash cd backend go run ./cmd/api ``` **4. Run the frontend** (serves `http://localhost:4200`) ```bash cd frontend npm install npm start ``` Open and sign in with any demo account below. ## Demo accounts Every seeded account uses the password **`demo1234`**. | Tenant | Email | 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: 1. **The tenant is resolved from credentials, not from client input.** The `X-Tenant` header (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. 2. **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`](backend/internal/handlers/handlers.go). 3. **Every tenant-scoped query filters on `tenant_id` as `$1`.** This is a stated convention in [`internal/repository`](backend/internal/repository/repository.go), and a lookup that misses returns `ErrNotFound` → 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/middleware`](backend/internal/middleware/middleware.go) and > [`internal/auth`](backend/internal/auth/auth.go), 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: ```bash 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](TESTING.md)** explains how they are structured; **[TEST-CATALOG.md](TEST-CATALOG.md)** lists every test by name and is regenerated with `npm run docs:tests`. ```bash 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`](.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` (or `npm 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.