232 lines
9.8 KiB
Markdown
232 lines
9.8 KiB
Markdown
# 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**
|
|
|
|
```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 <http://localhost:4200> 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.
|