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:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# --- secrets ----------------------------------------------------------------
.env
*.env
!.env.example
# --- go ---------------------------------------------------------------------
backend/bin/
backend/api
backend/api.exe
*.exe
*.test
# --- node / angular ---------------------------------------------------------
node_modules/
frontend/dist/
frontend/.angular/
frontend/tmp/
npm-debug.log*
yarn-error.log*
# --- editors / os -----------------------------------------------------------
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
# --- test artifacts ---------------------------------------------------------
e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
e2e/playwright/.cache/
backend/coverage.out
frontend/coverage/
e2e/evidence-report/
e2e/**/*.pdf
+231
View File
@@ -0,0 +1,231 @@
# 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.
+498
View File
@@ -0,0 +1,498 @@
# TravelHub test catalogue
> Generated by `npm run docs:tests` from the test sources.
> Do not edit by hand — regenerate instead.
**277 test cases** across 25 files.
| Suite | Files | Cases | Command |
| ----- | ----: | ----: | ------- |
| [Go](#go-backend) | 6 | 98 | `npm run test:go` |
| [Angular](#angular-frontend) | 12 | 114 | `npm run test:web` |
| [Playwright](#playwright-end-to-end) | 7 | 65 | `npm run test:e2e` |
| **Total** | **25** | **277** | `npm test` |
### Why the runners report different numbers
A *case* below is one thing being tested. The runners count differently, so
both figures are given rather than one being quietly preferred:
- **Go** reports 74 test functions plus each table-driven subtest
separately, so `go test -v` prints more `PASS` lines than there are cases
here. A few tables build their subtest names at runtime and cannot be
listed statically; those appear as a single case.
- **Playwright** re-runs `auth` and `responsive` (17 tests) under a second,
mobile viewport, so a full run executes 82 tests from 65 declared.
- **Angular** counts exactly as listed.
Running a single file:
```bash
cd backend && go test ./internal/repository/ -run TestCreateBookingSellsSeats -v
cd frontend && npx ng test --no-watch # all specs
cd e2e && npx playwright test tests/booking.spec.ts
```
---
## Go (backend)
74 test functions covering 98 cases. These run against a real
PostgreSQL, with a fresh tenant created and dropped per test.
### `backend/internal/config/config_test.go`
7 tests, 7 cases.
- **Load defaults** — `TestLoadDefaults`
- **Load requires a password** — `TestLoadRequiresAPassword`
- **Load escapes awkward passwords** — `TestLoadEscapesAwkwardPasswords`
- **Database url wins over parts** — `TestDatabaseURLWinsOverParts`
- **Load reads dot env** — `TestLoadReadsDotEnv`
- **Environment beats dot env** — `TestEnvironmentBeatsDotEnv`
- **Load finds dot env in a parent directory** — `TestLoadFindsDotEnvInAParentDirectory`
### `backend/internal/handlers/handlers_test.go`
22 tests, 33 cases.
- **Health needs no credentials** — `TestHealthNeedsNoCredentials`
- **Tenant list is public but thin** — `TestTenantListIsPublicButThin`
- **Protected routes reject anonymous** — `TestProtectedRoutesRejectAnonymous`
- **Session describes the caller** — `TestSessionDescribesTheCaller`
- **Session never returns the password hash** — `TestSessionNeverReturnsThePasswordHash`
- **Search flights returns a page** — `TestSearchFlightsReturnsAPage`
- **Search flights validates input** — `TestSearchFlightsValidatesInput`
- bad date
- bad cabin
- good cabin
- absurd limit is clamped
- negative offset falls back
- non-numeric passengers falls back
- **Get flight rejects a malformed id** — `TestGetFlightRejectsAMalformedID`
- **Get flight across tenants is four oh four** — `TestGetFlightAcrossTenantsIsFourOhFour`
- **Create booking returns created** — `TestCreateBookingReturnsCreated`
- **Create booking validation** — `TestCreateBookingValidation`
- missing flight id
- missing contact email
- malformed contact email
- no passengers
- blank passenger name
- more than nine passengers
- **Create booking rejects unknown fields** — `TestCreateBookingRejectsUnknownFields`
- **Create booking rejects malformed json** — `TestCreateBookingRejectsMalformedJSON`
- **Create booking over capacity is conflict** — `TestCreateBookingOverCapacityIsConflict`
- **Create booking on another tenants flight is four oh four** — `TestCreateBookingOnAnotherTenantsFlightIsFourOhFour`
- **Booking lifecycle over http** — `TestBookingLifecycleOverHTTP`
- **List bookings rejects an unknown status** — `TestListBookingsRejectsAnUnknownStatus`
- **Bookings are invisible to other tenants** — `TestBookingsAreInvisibleToOtherTenants`
- **Dashboard summarises the tenant** — `TestDashboardSummarisesTheTenant`
- **Reference lists are scoped to the tenant network** — `TestReferenceListsAreScopedToTheTenantNetwork`
- **Unknown route and wrong method** — `TestUnknownRouteAndWrongMethod`
- unknown path
- wrong method
- **Every json response carries its content type** — `TestEveryJSONResponseCarriesItsContentType`
### `backend/internal/middleware/middleware_test.go`
10 tests, 20 cases.
- **Basic auth accepts valid credentials** — `TestBasicAuthAcceptsValidCredentials`
- **Basic auth is case insensitive on email** — `TestBasicAuthIsCaseInsensitiveOnEmail`
- **Basic auth rejections** — `TestBasicAuthRejections`
- no credentials at all
- wrong password
- unknown user
- unknown tenant
- valid credentials against another tenant
- missing tenant header
- malformed authorization header
- **Basic auth refuses suspended tenant** — `TestBasicAuthRefusesSuspendedTenant`
- **Unauthorized does not trigger the browser dialog** — `TestUnauthorizedDoesNotTriggerTheBrowserDialog`
- **Tenant falls back to subdomain** — `TestTenantFallsBackToSubdomain`
- **Tenant header beats subdomain** — `TestTenantHeaderBeatsSubdomain`
- **Require admin** — `TestRequireAdmin`
- admin passes
- agent is refused
- **Cors** — `TestCORS`
- echoes an allowed origin
- tolerates a trailing slash in configuration
- ignores an origin that is not on the list
- answers preflight without reaching the handler
- **Recoverer turns panic into five hundred** — `TestRecovererTurnsPanicIntoFiveHundred`
### `backend/internal/repository/booking_repo_test.go`
15 tests, 15 cases.
- **Create booking sells seats** — `TestCreateBookingSellsSeats`
- **Create booking stores passenger detail** — `TestCreateBookingStoresPassengerDetail`
- **Create booking rejects blank date of birth** — `TestCreateBookingRejectsBlankDateOfBirth`
- **Create booking refuses more seats than remain** — `TestCreateBookingRefusesMoreSeatsThanRemain`
- **Create booking requires a passenger** — `TestCreateBookingRequiresAPassenger`
- **Create booking cannot reach another tenants flight** — `TestCreateBookingCannotReachAnotherTenantsFlight`
- **Create booking refuses cancelled flight** — `TestCreateBookingRefusesCancelledFlight`
- **Create booking does not oversell under concurrency** — `TestCreateBookingDoesNotOversellUnderConcurrency`
- **Cancel booking returns seats** — `TestCancelBookingReturnsSeats`
- **Cancel booking is not repeatable** — `TestCancelBookingIsNotRepeatable`
- **Cancel booking across tenants is not found** — `TestCancelBookingAcrossTenantsIsNotFound`
- **List bookings isolates tenants and filters status** — `TestListBookingsIsolatesTenantsAndFiltersStatus`
- **Booking by id across tenants is not found** — `TestBookingByIDAcrossTenantsIsNotFound`
- **Booking references are unique within a tenant** — `TestBookingReferencesAreUniqueWithinATenant`
- **Dashboard stats count only this tenant** — `TestDashboardStatsCountOnlyThisTenant`
### `backend/internal/repository/flight_repo_test.go`
13 tests, 16 cases.
- **Search flights filters by route** — `TestSearchFlightsFiltersByRoute`
- **Search flights lowercase iata still matches** — `TestSearchFlightsLowercaseIATAStillMatches`
- **Search flights isolates tenants** — `TestSearchFlightsIsolatesTenants`
- **Flight by id across tenants is not found** — `TestFlightByIDAcrossTenantsIsNotFound`
- **Flight by id unknown is not found** — `TestFlightByIDUnknownIsNotFound`
- **Search flights hides departed and cancelled** — `TestSearchFlightsHidesDepartedAndCancelled`
- **Search flights respects party size** — `TestSearchFlightsRespectsPartySize`
- **Search flights filters by cabin** — `TestSearchFlightsFiltersByCabin`
- **Search flights filters by departure day** — `TestSearchFlightsFiltersByDepartureDay`
- **Search flights sorting** — `TestSearchFlightsSorting`
- price
- duration
- departure
- nonsense-falls-back-to-price
- **Search flights paginates** — `TestSearchFlightsPaginates`
- **Search flights computes duration** — `TestSearchFlightsComputesDuration`
- **List airports covers only this tenants network** — `TestListAirportsCoversOnlyThisTenantsNetwork`
### `backend/internal/repository/identity_repo_test.go`
7 tests, 7 cases.
- **Tenant by slug** — `TestTenantBySlug`
- **Tenant by slug unknown is not found** — `TestTenantBySlugUnknownIsNotFound`
- **Tenant by slug suspended is not found** — `TestTenantBySlugSuspendedIsNotFound`
- **User by email is case insensitive** — `TestUserByEmailIsCaseInsensitive`
- **User by email is scoped to tenant** — `TestUserByEmailIsScopedToTenant`
- **Same email can exist in two tenants** — `TestSameEmailCanExistInTwoTenants`
- **List tenants excludes suspended** — `TestListTenantsExcludesSuspended`
---
## Angular (frontend)
114 tests. Vitest with jsdom, via `ng test`.
### `frontend/src/app/core/api.service.spec.ts`
10 tests.
- **ApiService › searchFlights**
- sends only the filters that were actually set
- drops empty strings so a cleared filter does not narrow the search
- keeps a zero offset, which is a real value rather than an absent one
- **ApiService**
- requests one flight by id
- **ApiService › listBookings**
- defaults to the first page of every status
- passes a status filter through when given
- **ApiService**
- posts a booking
- cancels a booking with an empty body
- reads the dashboard
- reads the tenant reference lists
### `frontend/src/app/core/auth.guard.spec.ts`
5 tests.
- **authGuard**
- lets a signed-in user through
- redirects to sign-in and remembers where the user was heading
- omits returnTo when the destination was the root
- **guestGuard**
- lets a signed-out visitor reach the sign-in screen
- sends an already signed-in user to the dashboard
### `frontend/src/app/core/auth.interceptor.spec.ts`
8 tests.
- **authInterceptor**
- stamps API calls with the credentials and tenant
- leaves non-API requests alone
- does not overwrite credentials a caller set explicitly
- adds nothing when signed out
- signs out and redirects when stored credentials go stale
- does not sign out on a 401 from an unstamped request
- leaves the session intact for other error statuses
- passes the error through to the caller
### `frontend/src/app/core/auth.service.spec.ts`
12 tests.
- **AuthService**
- starts signed out with nothing in storage
- fetches the public tenant list without credentials
- verifies credentials against the chosen tenant before storing them
- leaves no trace when the server rejects the credentials
- reports the admin role
- revalidates stored credentials on restore
- discards stored credentials the server no longer accepts
- restores without a second round trip once the session is known
- short-circuits restore when there are no credentials
- clears everything on sign out
- treats unreadable storage as signed out rather than crashing
- ignores a stored value of the wrong shape
### `frontend/src/app/features/bookings/booking-detail.spec.ts`
8 tests.
- **BookingDetailPage**
- loads the booking with its travellers
- shows the confirmation banner only when arriving from checkout
- asks for confirmation before cancelling
- lets the user back out
- cancels on confirmation and reflects the new status
- reports a cancel that failed and leaves the booking alone
- offers no cancel control on an already cancelled booking
- shows a not-found state for a booking in another agency
### `frontend/src/app/features/bookings/bookings.spec.ts`
6 tests.
- **BookingsPage**
- loads every booking on open
- filters by status and starts again from page one
- pages through a long list
- shows an empty state with a way forward
- surfaces a load failure
- renders a row with the reference, route and status
### `frontend/src/app/features/checkout/checkout.spec.ts`
16 tests.
- **CheckoutPage**
- loads the flight and prefills the agent as the contact
- opens with one traveller form by default
- honours the traveller count carried over from search
- clamps a silly traveller count to what the cabin allows
- never asks for more travellers than there are seats
- ignores a non-numeric traveller count
- adds and removes travellers, repricing as it goes
- will not go below one traveller or above nine
- warns when the party outgrows the remaining seats
- blocks submission until every required field is filled
- submits the booking and moves to the confirmation
- explains a seat sold out from under the form
- explains a flight that disappeared
- relays the server`s own validation message
- shows a not-found state when the flight cannot be loaded
- renders cabin codes as words
### `frontend/src/app/features/dashboard/dashboard.spec.ts`
7 tests.
- **DashboardPage**
- greets the user by first name and names the agency
- renders the headline figures
- scales the route bars against the biggest earner
- keeps a tiny route visible rather than drawing nothing
- copes with no sales at all
- treats a null topRoutes as an empty list
- surfaces a failure to load
### `frontend/src/app/features/login/login.spec.ts`
14 tests.
- **LoginPage**
- loads the agency list on open
- preselects the only agency in a single-tenant deployment
- does not preselect when there is a real choice to make
- explains a backend that is not running
- fills the form from a demo chip
- refuses to submit an incomplete form
- rejects a malformed email before calling the server
- signs in and moves to the dashboard
- returns the user to the page they were trying to reach
- reports wrong credentials in the agency`s terms
- reports an unreachable API distinctly from a bad password
- falls back to a generic message for unexpected failures
- renders one option per agency plus the placeholder
- shows four demo account shortcuts
### `frontend/src/app/features/search/search.spec.ts`
16 tests.
- **SearchPage**
- searches once on open and fills the airport pickers
- asks for the first page with the default page size
- passes the filter panel through to the API
- returns to page one when the filters change
- clears the filters on reset
- **SearchPage › pagination**
- describes the visible slice
- moves forward and back
- never steps past the ends
- **SearchPage**
- reports an empty result set rather than an error
- surfaces a failed search
- carries the traveller count into checkout
- **SearchPage › presentation helpers**
- marks an arrival on a later calendar day
- shows no offset for a same-day arrival
- flags scarce inventory as urgent
- renders cabin codes as words
- **SearchPage**
- renders a result card with the fare and route
### `frontend/src/app/layout/shell.spec.ts`
8 tests.
- **Shell**
- keeps the agency permanently on screen
- shows who is signed in
- **Shell › initials**
- takes the first letter of the first two names
- copes with a single name
- copes with extra spacing
- falls back when there is no name yet
- **Shell**
- offers the three main destinations
- signs out and returns to the sign-in screen
### `frontend/src/app/shared/duration.pipe.spec.ts`
4 tests.
- **DurationPipe**
- renders hours and zero-padded minutes
- drops the hour part under an hour
- handles long-haul durations past a day
- renders a placeholder for missing or nonsensical values
---
## Playwright (end to end)
65 tests, each run against a real browser and the real stack.
### `e2e/tests/auth.spec.ts`
13 tests.
- **Sign in**
- offers every active agency in the picker
- a demo shortcut fills the whole form
- signs an agent into their own agency
- signs an admin in and shows the admin role
- rejects a wrong password
- rejects valid credentials aimed at the wrong agency
- will not submit without an agency
- rejects a malformed email before contacting the server
- **Session**
- survives a page reload
- sends an anonymous visitor to sign-in and back again
- signing out clears the session for good
- an already signed-in user is moved off the sign-in screen
- stale stored credentials are discarded
### `e2e/tests/booking.spec.ts`
7 tests.
- **Booking a flight**
- carries the fare and traveller count into checkout
- reprices as travellers are added and removed
- refuses to submit with missing traveller names
- books, confirms, lists and cancels, returning the seats
- a cancel can be backed out of
- the bookings list filters by status
- a booking opened directly shows no stale confirmation banner
### `e2e/tests/dashboard.spec.ts`
6 tests.
- **Dashboard**
- greets the agent and names the agency
- shows the four headline figures
- lists recent bookings and best routes from the seed data
- a new sale moves the figures, and cancelling moves them back
- a recent booking row opens the booking
- the new booking shortcut goes to search
### `e2e/tests/responsive.spec.ts`
4 tests.
- **Layout**
- the sign-in screen fits without sideways scrolling
- the console screens fit at phone width
- wide tables scroll inside their own container
- the sidebar collapses into a top bar on narrow screens
### `e2e/tests/search.spec.ts`
11 tests.
- **Flight search**
- opens with the agency inventory already listed
- offers only airports this agency flies
- narrows results to a route
- a route with no inventory shows an empty state
- resets back to the full list
- sorts by lowest fare
- sorts by shortest trip
- filters by cabin
- hides flights without room for the whole party
- pages through results without repeating them
- a result card carries everything needed to choose
### `e2e/tests/simulation.spec.ts`
19 tests.
- **When the API fails**
- search shows an error instead of an empty page
- the dashboard explains itself rather than showing zeroes
- the bookings list reports a failure
- a missing backend is named on the sign-in screen
- going offline mid-session surfaces a failure, not a hang
- **When the API is slow**
- search shows skeletons while it waits
- the sign-in button reports that it is working
- the dashboard shows skeleton tiles before its figures
- **When the session expires**
- a 401 mid-session returns the agent to sign-in
- **Edge cases in the data**
- an agency with no inventory sees an empty state, not an error
- a brand new agency sees zeroes without a broken dashboard
- the last seat is flagged as urgent
- a seat sold during checkout is explained clearly
- a party larger than the cabin is blocked before submitting
- a huge result set still paginates sensibly
- **Awkward content**
- very long names do not break the layout or escape the page
- markup in data is rendered as text, never as HTML
- **Keyboard operation**
- the sign-in form can be completed without a mouse
- every interactive control on search is reachable by tab
### `e2e/tests/tenant-isolation.spec.ts`
5 tests.
- **Tenant isolation**
- the same route is priced per agency
- the sidebar always names the agency you are working in
- one agency never sees another agency booking
- revenue figures are per agency
- a deep link to another agency flight is refused
+391
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
# Copy to .env and fill in real values. .env is git-ignored.
# --- database ---------------------------------------------------------------
# Either set DATABASE_URL directly, or set the individual DB_* parts below and
# let the app assemble the URL. DATABASE_URL wins if both are present.
# DATABASE_URL=postgres://postgres:password@localhost:5432/travelhub?sslmode=disable
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=change-me
DB_NAME=travelhub
DB_SSLMODE=disable
# --- server -----------------------------------------------------------------
PORT=8080
# Comma-separated list of origins allowed to call the API from a browser.
CORS_ORIGINS=http://localhost:4200
+80
View File
@@ -0,0 +1,80 @@
// Command api serves the TravelHub multi-tenant flight booking API.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/travelswitch/travelhub/backend/internal/config"
"github.com/travelswitch/travelhub/backend/internal/database"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
if err := run(logger); err != nil {
logger.Error("fatal", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
// Interrupt cancels this context, which unwinds both the pool and the
// server below.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := database.Connect(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
logger.Info("connected to postgres")
repo := repository.New(pool)
h := handlers.New(repo, logger)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: httpapi.New(h, repo, logger, cfg.CORSOrigins),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", srv.Addr, "cors", cfg.CORSOrigins)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
+512
View File
@@ -0,0 +1,512 @@
mode: set
github.com/travelswitch/travelhub/backend/internal/config/config.go:26.2,27.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:28.2,31.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:33.2,33.49 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:34.3,35.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:36.3,42.1 7 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:43.3,43.17 7 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:44.4,45.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:49.3,55.14 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:58.2,58.17 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:66.2,67.16 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:68.3,69.1 1 0
github.com/travelswitch/travelhub/backend/internal/config/config.go:71.2,71.25 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:72.3,73.25 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:74.4,75.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:76.3,77.20 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:78.4,79.1 1 0
github.com/travelswitch/travelhub/backend/internal/config/config.go:80.3,80.15 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:86.2,87.16 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:88.3,89.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:90.2,91.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:92.2,93.21 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:94.3,95.49 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:96.4,96.12 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:98.3,99.13 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:100.4,100.12 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:102.3,104.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:106.3,106.22 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:107.4,108.55 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:109.5,110.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:113.3,113.46 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:114.4,115.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:117.2,117.13 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:121.2,121.34 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:122.3,123.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:124.2,124.17 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:128.2,130.26 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:131.3,131.40 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:132.4,133.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:135.2,135.12 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:28.2,28.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:29.3,29.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:30.4,30.17 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:31.5,31.37 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:32.6,35.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:37.4,37.24 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:49.2,51.1 2 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:55.2,55.46 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:56.3,56.72 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:57.4,67.1 4 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:73.2,74.28 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:75.3,76.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:78.2,78.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:79.3,79.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:80.4,81.40 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:82.5,88.1 7 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:89.5,90.1 7 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:92.4,92.38 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:93.5,95.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:96.4,96.24 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:108.2,108.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:109.3,109.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:110.4,111.18 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:112.5,115.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:117.4,118.11 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:119.5,121.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:123.4,125.18 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:126.5,126.47 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:129.6,131.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:132.5,134.11 3 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:137.4,138.18 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:139.5,139.47 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:143.6,146.1 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:147.5,149.11 3 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:152.4,152.89 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:153.5,155.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:160.4,160.71 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:161.5,163.1 2 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:165.4,166.41 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:173.2,173.71 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:174.3,175.34 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:176.4,178.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:179.3,179.23 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:189.2,189.65 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:190.3,191.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:193.2,194.50 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:195.3,196.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:197.2,198.44 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:199.3,200.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:201.2,201.11 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:208.2,209.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:212.2,215.1 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:219.2,219.36 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:220.3,221.1 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:222.2,222.10 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:15.2,16.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:17.3,18.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:20.2,21.68 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:22.3,24.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:26.2,28.1 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:29.2,30.16 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:31.3,33.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:35.2,37.4 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:42.2,43.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:44.3,45.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:47.2,48.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:49.3,51.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:53.2,54.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:55.3,57.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:58.2,58.38 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:63.2,64.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:65.3,66.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:68.2,71.41 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:72.3,74.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:76.2,76.45 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:77.3,79.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:81.2,82.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:83.3,85.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:86.2,86.43 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:92.2,93.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:94.3,95.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:97.2,98.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:99.3,101.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:103.2,104.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:105.3,107.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:108.2,108.38 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:115.2,115.30 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:116.3,117.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:119.2,120.28 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:121.3,122.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:123.2,123.63 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:124.3,125.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:126.2,127.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:128.2,128.30 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:129.3,130.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:131.2,131.29 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:132.3,133.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:135.2,135.32 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:136.3,141.1 6 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:142.3,142.44 6 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:143.4,144.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:146.2,146.11 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:151.2,152.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:153.3,154.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:156.2,157.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:158.3,160.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:161.2,161.36 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:17.2,18.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:19.3,20.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:22.2,31.1 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:33.2,33.56 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:36.3,37.17 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:38.4,40.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:41.3,41.25 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:44.2,44.63 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:45.3,48.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:50.2,51.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:52.3,54.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:56.2,58.4 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:63.2,64.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:65.3,66.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:68.2,69.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:70.3,72.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:74.2,75.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:76.3,78.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:79.2,79.37 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:84.2,85.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:86.3,87.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:89.2,90.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:91.3,93.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:94.2,94.39 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:99.2,100.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:101.3,102.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:104.2,105.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:106.3,108.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:109.2,109.39 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:113.2,113.11 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:115.3,115.14 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:117.2,117.14 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:28.2,29.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:40.2,42.17 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:43.3,44.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:48.2,49.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:54.2,54.9 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:56.3,56.50 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:58.3,58.82 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:60.3,61.73 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:68.2,69.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:70.3,72.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:73.2,73.17 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:78.2,79.15 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:80.3,81.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:82.2,83.27 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:84.3,85.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:86.2,86.13 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:87.3,88.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:89.2,89.10 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:95.2,96.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:14.2,15.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:16.3,18.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:20.2,23.1 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:24.2,25.28 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:26.3,27.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:28.2,28.36 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:35.2,36.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:37.3,38.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:40.2,43.38 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:36.2,51.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:52.3,53.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:54.2,56.15 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:63.2,64.21 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:65.3,66.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:67.4,68.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:69.3,69.34 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:71.2,71.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:81.2,81.30 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:82.3,83.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:85.2,86.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:87.3,88.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:89.2,90.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:91.2,101.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:102.2,103.35 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:104.3,105.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:106.2,106.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:107.3,108.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:110.2,111.23 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:112.3,113.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:115.2,117.79 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:118.3,119.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:121.2,127.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:129.2,130.43 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:131.3,132.18 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:133.4,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:135.3,139.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:140.4,140.9 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:142.3,142.30 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:143.4,144.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:146.2,146.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:147.3,148.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:150.2,154.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:155.2,155.35 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:156.3,157.26 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:158.4,158.76 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:159.5,160.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:162.3,163.64 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:164.4,165.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:168.2,168.39 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:169.3,170.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:172.2,172.48 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:177.2,178.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:179.2,180.35 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:181.3,182.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:183.2,183.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:184.3,185.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:187.2,188.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:189.3,190.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:191.2,191.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:192.3,193.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:194.2,194.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:201.2,203.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:204.3,206.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:208.2,209.109 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:210.3,211.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:213.2,213.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:214.3,215.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:216.2,216.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:217.3,218.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:220.2,223.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:224.2,225.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:226.3,227.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:228.2,229.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:230.2,232.18 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:233.3,234.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:235.4,236.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:237.3,238.26 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:240.2,240.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:241.3,242.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:244.2,244.18 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:245.3,246.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:247.4,248.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:249.3,249.27 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:250.4,250.43 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:251.5,252.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:256.2,256.29 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:261.2,262.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:263.3,264.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:265.2,266.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:267.2,271.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:274.2,278.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:279.2,280.35 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:281.3,282.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:283.2,283.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:284.3,285.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:287.2,291.79 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:292.3,293.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:295.2,295.39 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:296.3,297.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:298.2,298.48 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:304.2,310.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:311.2,312.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:313.3,314.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:315.2,316.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:317.2,318.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:319.3,324.66 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:325.4,326.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:327.3,327.45 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:329.2,329.24 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:30.2,41.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:42.3,43.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:44.2,45.15 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:52.2,54.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:55.2,55.40 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:56.3,58.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:62.2,62.20 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:63.3,64.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:65.2,65.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:66.3,67.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:68.2,68.24 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:69.3,70.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:71.2,71.22 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:72.3,73.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:74.2,74.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:76.3,80.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:82.2,82.20 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:88.2,88.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:90.3,90.78 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:92.3,92.59 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:94.3,94.59 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:101.2,103.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:104.2,106.77 5 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:107.3,108.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:110.2,111.31 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:112.3,113.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:114.2,115.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:116.3,117.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:119.2,122.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:123.2,124.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:125.3,126.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:127.2,128.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:129.2,130.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:131.3,132.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:133.4,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:135.3,135.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:137.2,137.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:142.2,143.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:144.2,145.35 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:146.3,147.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:148.2,148.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:15.2,19.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:20.2,23.35 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:24.3,25.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:26.2,26.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:33.2,38.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:39.2,40.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:41.3,42.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:43.2,44.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:45.2,46.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:47.3,48.85 2 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:49.4,50.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:51.3,51.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:53.2,53.28 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:59.2,63.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:64.2,68.35 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:69.3,70.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:71.2,71.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:14.2,23.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:24.2,25.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:26.3,27.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:28.2,29.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:30.2,31.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:32.3,33.98 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:34.4,35.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:36.3,36.33 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:38.2,38.29 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:43.2,48.1 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:49.2,50.16 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:51.3,52.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:53.2,54.1 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:55.2,56.18 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:57.3,58.76 2 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:59.4,60.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:61.3,61.33 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:63.2,63.29 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:68.2,70.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:71.2,79.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:80.2,83.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:84.3,85.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:87.2,90.89 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:91.3,92.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:94.2,106.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:107.2,108.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:109.3,110.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:111.2,112.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:113.2,113.18 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:114.3,118.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:119.3,119.82 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:120.4,121.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:122.3,122.43 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:124.2,124.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:125.3,126.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:128.2,129.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:130.3,131.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:132.2,133.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:134.2,134.15 2 1
github.com/travelswitch/travelhub/backend/internal/repository/repository.go:31.2,32.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/repository.go:37.2,39.1 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:16.2,17.16 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:18.3,19.1 1 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:21.2,25.1 6 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:26.2,27.16 6 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:28.3,29.1 1 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:31.2,33.1 3 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:34.2,34.43 3 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:35.3,37.1 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:39.2,39.18 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:32.2,33.1 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:37.2,38.9 2 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:39.3,40.1 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:41.2,41.16 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:46.2,47.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:53.2,54.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:55.2,57.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:58.2,59.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:60.2,66.16 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:67.3,68.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:70.2,70.19 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:72.3,73.64 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:74.4,75.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:78.2,81.10 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:86.2,87.1 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:89.2,90.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:91.3,92.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:94.2,101.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:102.3,103.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:104.2,104.10 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:123.2,125.1 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:126.2,126.23 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:127.3,128.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:129.2,129.23 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:130.3,131.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:132.2,132.28 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:133.3,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:135.2,135.25 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:136.3,137.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:138.2,138.24 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:139.3,140.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:141.2,141.22 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:142.3,143.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:144.2,144.21 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:145.3,146.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:147.2,147.21 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:148.3,149.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:150.2,150.23 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:151.3,152.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:153.2,154.27 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:155.3,156.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:158.2,159.1 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:160.2,177.16 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:178.3,179.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:181.2,182.16 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:183.3,184.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:185.2,185.15 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:191.2,192.1 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:193.2,196.16 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:197.3,198.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:199.2,199.14 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:205.2,206.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:207.2,208.85 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:209.3,210.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:215.2,216.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:217.2,217.21 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:220.3,221.1 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:222.3,224.1 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:225.3,226.17 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:227.4,229.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:230.3,230.37 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:231.4,233.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:234.3,234.45 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:235.4,237.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:238.3,238.47 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:239.4,241.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:242.3,242.11 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:245.2,245.20 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:246.3,249.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:250.2,250.13 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:257.2,257.54 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:258.3,259.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:261.2,266.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:267.2,273.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:274.2,274.19 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:278.2,278.34 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:279.3,280.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:281.2,281.17 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:286.2,288.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:289.3,290.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:293.2,293.52 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:294.3,295.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:296.2,296.12 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:301.2,313.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:314.2,314.44 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:315.3,316.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:317.2,317.12 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:323.2,324.9 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:325.3,326.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:328.2,328.59 1 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:29.2,30.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:31.2,32.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:33.2,37.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:38.2,42.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:43.2,44.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:45.2,49.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:50.2,55.1 16 0
+17
View File
@@ -0,0 +1,17 @@
module github.com/travelswitch/travelhub/backend
go 1.26.0
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.57.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+47
View File
@@ -0,0 +1,47 @@
// Package auth carries the authenticated caller through the request context.
//
// Every tenant-scoped query in this service takes its tenant id from the
// Identity stored here, never from anything the client can name directly in a
// query string or body. That is the single rule that keeps tenants isolated.
package auth
import (
"context"
"errors"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// Identity is the authenticated user together with the tenant they signed into.
type Identity struct {
User models.User
Tenant models.Tenant
}
// contextKey is unexported so no other package can collide with our key.
type contextKey struct{}
var identityKey contextKey
// ErrNoIdentity means the handler ran without authentication middleware in
// front of it, which is a programming error rather than a client error.
var ErrNoIdentity = errors.New("no identity in request context")
// WithIdentity returns a context carrying the identity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey, id)
}
// FromContext retrieves the identity placed by the auth middleware.
func FromContext(ctx context.Context) (Identity, error) {
id, ok := ctx.Value(identityKey).(Identity)
if !ok {
return Identity{}, ErrNoIdentity
}
return id, nil
}
// IsAdmin reports whether the caller holds the tenant admin role.
func (i Identity) IsAdmin() bool {
return i.User.Role == "admin"
}
+136
View File
@@ -0,0 +1,136 @@
// Package config loads runtime settings from the environment, optionally
// seeded from a .env file sitting next to the binary or at the module root.
package config
import (
"bufio"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
)
// Config holds everything the API needs to start.
type Config struct {
DatabaseURL string
Port string
CORSOrigins []string
}
// Load reads .env (if present) into the process environment, then builds a
// Config. Real environment variables always win over .env entries, so
// container and CI overrides behave the way people expect.
func Load() (*Config, error) {
LoadDotEnv()
cfg := &Config{
Port: envOr("PORT", "8080"),
CORSOrigins: splitAndTrim(envOr("CORS_ORIGINS", "http://localhost:4200")),
}
if raw := os.Getenv("DATABASE_URL"); raw != "" {
cfg.DatabaseURL = raw
} else {
user := envOr("DB_USER", "postgres")
pass := os.Getenv("DB_PASSWORD")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("DB_NAME", "travelhub")
ssl := envOr("DB_SSLMODE", "disable")
if pass == "" {
return nil, fmt.Errorf("database password is not set: define DATABASE_URL or DB_PASSWORD (see .env.example)")
}
// url.UserPassword escapes credentials for us, which matters because
// passwords routinely contain @ : / and other reserved characters.
cfg.DatabaseURL = (&url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: net.JoinHostPort(host, port),
Path: "/" + name,
RawQuery: "sslmode=" + ssl,
}).String()
}
return cfg, nil
}
// LoadDotEnv walks up from the working directory looking for a .env file and
// applies any keys that are not already set in the environment. Load calls it;
// it is exported so tests can pick up the same local credentials without
// building a whole Config.
func LoadDotEnv() {
dir, err := os.Getwd()
if err != nil {
return
}
for i := 0; i < 4; i++ {
path := filepath.Join(dir, ".env")
if applyEnvFile(path) {
return
}
parent := filepath.Dir(dir)
if parent == dir {
return
}
dir = parent
}
}
// applyEnvFile reads one .env file. It reports whether the file existed.
func applyEnvFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
// Allow quoted values so passwords may contain leading/trailing spaces.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, value)
}
}
return true
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func splitAndTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/travelswitch/travelhub/backend/internal/config"
)
// isolate points the process at an empty directory and clears every variable
// Load reads, so one test cannot inherit another's environment -- or the
// developer's real .env.
func isolate(t *testing.T) {
t.Helper()
for _, key := range []string{
"DATABASE_URL", "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD",
"DB_NAME", "DB_SSLMODE", "PORT", "CORS_ORIGINS",
} {
t.Setenv(key, "")
os.Unsetenv(key)
}
t.Chdir(t.TempDir())
}
func TestLoadDefaults(t *testing.T) {
isolate(t)
t.Setenv("DB_PASSWORD", "secret")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "8080" {
t.Errorf("Port = %q, want 8080", cfg.Port)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "http://localhost:4200" {
t.Errorf("CORSOrigins = %v, want the Angular dev server", cfg.CORSOrigins)
}
if !strings.Contains(cfg.DatabaseURL, "/travelhub") {
t.Errorf("DatabaseURL = %q, want the default database name", cfg.DatabaseURL)
}
}
func TestLoadRequiresAPassword(t *testing.T) {
isolate(t)
_, err := config.Load()
if err == nil {
t.Fatal("want an error when neither DATABASE_URL nor DB_PASSWORD is set")
}
if !strings.Contains(err.Error(), "DB_PASSWORD") {
t.Errorf("error should name the missing variable, got: %v", err)
}
}
// The credentials in use here contain '@', which is exactly the character that
// breaks naive string concatenation into a DSN.
func TestLoadEscapesAwkwardPasswords(t *testing.T) {
isolate(t)
awkward := []string{
// Synthetic values only — never a real, in-use password (see the VAPT
// report, FINDING-8). Each still exercises a reserved DSN character.
"S3cr3t@x",
"p@ss:word/with?reserved#chars",
"has spaces and %25 percent",
"trailing@@@",
}
for _, password := range awkward {
t.Run(password, func(t *testing.T) {
t.Setenv("DB_PASSWORD", password)
t.Setenv("DB_USER", "postgres")
t.Setenv("DB_NAME", "travelhub")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
// The real proof is that the driver can parse it back out.
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("pgx could not parse %q: %v", cfg.DatabaseURL, err)
}
if parsed.ConnConfig.Password != password {
t.Errorf("round-tripped password = %q, want %q",
parsed.ConnConfig.Password, password)
}
if parsed.ConnConfig.Database != "travelhub" {
t.Errorf("database = %q, want travelhub", parsed.ConnConfig.Database)
}
})
}
}
func TestDatabaseURLWinsOverParts(t *testing.T) {
isolate(t)
const explicit = "postgres://someone:else@db.internal:6543/other?sslmode=require"
t.Setenv("DATABASE_URL", explicit)
t.Setenv("DB_PASSWORD", "ignored")
t.Setenv("DB_NAME", "ignored")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.DatabaseURL != explicit {
t.Errorf("DatabaseURL = %q, want the explicit value", cfg.DatabaseURL)
}
}
func TestLoadReadsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
contents := strings.Join([]string{
"# a comment",
"",
"DB_PASSWORD=from-file",
"DB_NAME=from-file-db",
`DB_USER="quoted-user"`,
"PORT=9999",
"CORS_ORIGINS=http://a.test, http://b.test ,",
"MALFORMED-LINE-WITHOUT-EQUALS",
}, "\n")
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(contents), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "9999" {
t.Errorf("Port = %q, want 9999 from .env", cfg.Port)
}
if got := cfg.CORSOrigins; len(got) != 2 || got[0] != "http://a.test" || got[1] != "http://b.test" {
t.Errorf("CORSOrigins = %v, want two trimmed origins", got)
}
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.ConnConfig.Password != "from-file" {
t.Errorf("password = %q, want from-file", parsed.ConnConfig.Password)
}
if parsed.ConnConfig.Database != "from-file-db" {
t.Errorf("database = %q, want from-file-db", parsed.ConnConfig.Database)
}
// Surrounding quotes are stripped rather than taken literally.
if parsed.ConnConfig.User != "quoted-user" {
t.Errorf("user = %q, want quoted-user", parsed.ConnConfig.User)
}
}
// Real environment variables must beat .env, so container and CI overrides work.
func TestEnvironmentBeatsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"),
[]byte("DB_PASSWORD=from-file\nPORT=1111\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
t.Setenv("PORT", "2222")
t.Setenv("DB_PASSWORD", "from-environment")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "2222" {
t.Errorf("Port = %q, want the environment value 2222", cfg.Port)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "from-environment" {
t.Errorf("password = %q, want from-environment", parsed.ConnConfig.Password)
}
}
func TestLoadFindsDotEnvInAParentDirectory(t *testing.T) {
isolate(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".env"),
[]byte("DB_PASSWORD=parent-secret\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
nested := filepath.Join(root, "cmd", "api")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
t.Chdir(nested)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "parent-secret" {
t.Errorf("password = %q, want parent-secret", parsed.ConnConfig.Password)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Package database owns the PostgreSQL connection pool.
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect opens a pooled connection and verifies it with a ping, so a bad
// password or an unreachable server fails at startup rather than on the first
// request.
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"net/mail"
"strings"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListBookings handles GET /api/bookings, optionally filtered by status.
func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && status != "confirmed" && status != "cancelled" {
writeError(w, http.StatusBadRequest, "status must be confirmed or cancelled")
return
}
limit := intParam(r, "limit", 25, 1, 100)
offset := intParam(r, "offset", 0, 0, 100000)
bookings, total, err := h.Repo.ListBookings(r.Context(), id.Tenant.ID, status, limit, offset)
if err != nil {
h.fail(w, err, "list bookings")
return
}
writeJSON(w, http.StatusOK, page[models.Booking]{
Items: bookings, Total: total, Limit: limit, Offset: offset,
})
}
// GetBooking handles GET /api/bookings/{id}.
func (h *Handler) GetBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.BookingByID(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "get booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// CreateBooking handles POST /api/bookings.
func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
var req models.CreateBookingRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if msg := validateBooking(&req); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
booking, err := h.Repo.CreateBooking(r.Context(), id.Tenant.ID, id.User.ID, req)
if err != nil {
h.fail(w, err, "create booking")
return
}
writeJSON(w, http.StatusCreated, booking)
}
// CancelBooking handles POST /api/bookings/{id}/cancel and returns the seats
// to inventory.
func (h *Handler) CancelBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.CancelBooking(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "cancel booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// validateBooking returns an empty string when the payload is usable, or a
// message naming the first problem. Normalisation happens here too, so the
// repository only ever sees trimmed values.
func validateBooking(req *models.CreateBookingRequest) string {
if req.FlightID == uuid.Nil {
return "flightId is required"
}
req.ContactEmail = strings.TrimSpace(req.ContactEmail)
if req.ContactEmail == "" {
return "contactEmail is required"
}
if _, err := mail.ParseAddress(req.ContactEmail); err != nil {
return "contactEmail is not a valid email address"
}
req.ContactPhone = strings.TrimSpace(req.ContactPhone)
if len(req.Passengers) == 0 {
return "at least one passenger is required"
}
if len(req.Passengers) > 9 {
return "a single booking may carry at most 9 passengers"
}
for i := range req.Passengers {
p := &req.Passengers[i]
p.FirstName = strings.TrimSpace(p.FirstName)
p.LastName = strings.TrimSpace(p.LastName)
p.PassportNumber = strings.TrimSpace(p.PassportNumber)
p.DateOfBirth = strings.TrimSpace(p.DateOfBirth)
if p.FirstName == "" || p.LastName == "" {
return "every passenger needs a first and last name"
}
}
return ""
}
// Dashboard handles GET /api/dashboard.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
stats, err := h.Repo.DashboardStats(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "dashboard stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// SearchFlights handles GET /api/flights.
//
// Query parameters: origin, destination (IATA codes), date (YYYY-MM-DD),
// cabin, passengers, sort (price|duration|departure), limit, offset.
func (h *Handler) SearchFlights(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
q := r.URL.Query()
search := models.FlightSearch{
Origin: strings.ToUpper(strings.TrimSpace(q.Get("origin"))),
Destination: strings.ToUpper(strings.TrimSpace(q.Get("destination"))),
CabinClass: strings.TrimSpace(q.Get("cabin")),
Passengers: intParam(r, "passengers", 1, 1, 9),
SortBy: q.Get("sort"),
Limit: intParam(r, "limit", 25, 1, 100),
Offset: intParam(r, "offset", 0, 0, 100000),
}
if raw := strings.TrimSpace(q.Get("date")); raw != "" {
// Parsed in the server's local zone, which is what an agent selling
// from a desk in that zone means by "flights on the 12th".
d, err := time.ParseInLocation("2006-01-02", raw, time.Local)
if err != nil {
writeError(w, http.StatusBadRequest, "date must be in YYYY-MM-DD format")
return
}
search.DepartDate = &d
}
if search.CabinClass != "" && !validCabin(search.CabinClass) {
writeError(w, http.StatusBadRequest,
"cabin must be one of economy, premium_economy, business, first")
return
}
flights, total, err := h.Repo.SearchFlights(r.Context(), id.Tenant.ID, search)
if err != nil {
h.fail(w, err, "search flights")
return
}
writeJSON(w, http.StatusOK, page[models.Flight]{
Items: flights, Total: total, Limit: search.Limit, Offset: search.Offset,
})
}
// GetFlight handles GET /api/flights/{id}.
func (h *Handler) GetFlight(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
flightID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid flight id")
return
}
flight, err := h.Repo.FlightByID(r.Context(), id.Tenant.ID, flightID)
if err != nil {
h.fail(w, err, "get flight")
return
}
writeJSON(w, http.StatusOK, flight)
}
// ListAirports handles GET /api/airports.
func (h *Handler) ListAirports(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airports, err := h.Repo.ListAirports(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airports")
return
}
writeJSON(w, http.StatusOK, airports)
}
// ListAirlines handles GET /api/airlines.
func (h *Handler) ListAirlines(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airlines, err := h.Repo.ListAirlines(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airlines")
return
}
writeJSON(w, http.StatusOK, airlines)
}
func validCabin(c string) bool {
switch c {
case "economy", "premium_economy", "business", "first":
return true
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Package handlers implements the TravelHub JSON API.
//
// Handlers never read a tenant id from the URL, query string, or body. They
// take it from the authenticated identity, which the Basic auth middleware
// resolved from the credentials. That keeps "which tenant am I acting as"
// impossible for a client to influence.
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Handler carries the dependencies shared by every endpoint.
type Handler struct {
Repo *repository.Repository
Logger *slog.Logger
}
// New builds a Handler.
func New(repo *repository.Repository, logger *slog.Logger) *Handler {
return &Handler{Repo: repo, Logger: logger}
}
// page is the envelope for every list endpoint.
type page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// fail maps a repository error onto a status code, logging only the ones that
// represent a server fault.
func (h *Handler) fail(w http.ResponseWriter, err error, context string) {
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrNoSeats):
writeError(w, http.StatusConflict, "not enough seats available on this flight")
default:
h.Logger.Error(context, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
// identity pulls the authenticated caller out of the request, replying 401 if
// the route was somehow mounted without the auth middleware.
func (h *Handler) identity(w http.ResponseWriter, r *http.Request) (auth.Identity, bool) {
id, err := auth.FromContext(r.Context())
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return auth.Identity{}, false
}
return id, true
}
// intParam reads a bounded integer query parameter.
func intParam(r *http.Request, name string, fallback, min, max int) int {
raw := r.URL.Query().Get(name)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v < min {
return fallback
}
if v > max {
return max
}
return v
}
// Health reports liveness. It is the only endpoint that needs neither
// credentials nor a tenant.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+511
View File
@@ -0,0 +1,511 @@
package handlers_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// api spins the real router over a per-test tenant, so these exercise the same
// routing, middleware order and auth rules the server runs.
type api struct {
*testsupport.Fixture
handler http.Handler
}
func newAPI(t *testing.T) *api {
t.Helper()
f := testsupport.Setup(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
h := handlers.New(f.Repo, logger)
return &api{
Fixture: f,
handler: httpapi.New(h, f.Repo, logger, []string{"http://localhost:4200"}),
}
}
// do issues an authenticated request as the fixture's agent.
func (a *api) do(t *testing.T, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
return a.as(t, a.Agent.Email, testsupport.Password, a.Tenant.Slug, method, path, body)
}
// anon issues a request with no credentials.
func (a *api) anon(t *testing.T, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func (a *api) as(t *testing.T, email, password, tenant, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(email+":"+password)))
req.Header.Set(middleware.TenantHeader, tenant)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %s: %v", rec.Body, err)
}
return out
}
func requireStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body)
}
}
// --- public endpoints ------------------------------------------------------
func TestHealthNeedsNoCredentials(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/health")
requireStatus(t, rec, http.StatusOK)
got := decode[map[string]string](t, rec)
if got["status"] != "ok" {
t.Errorf("body = %v, want status ok", got)
}
}
func TestTenantListIsPublicButThin(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/tenants")
requireStatus(t, rec, http.StatusOK)
// The picker needs a slug and a name; it must not leak ids or timestamps.
got := decode[[]map[string]any](t, rec)
found := false
for _, tn := range got {
if tn["slug"] == a.Tenant.Slug {
found = true
if _, leaked := tn["id"]; leaked {
t.Error("tenant list leaked internal ids")
}
if len(tn) != 2 {
t.Errorf("tenant entry has %d fields, want just slug and name: %v", len(tn), tn)
}
}
}
if !found {
t.Error("the fixture's tenant is missing from the public list")
}
}
func TestProtectedRoutesRejectAnonymous(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/session", "/api/flights", "/api/bookings", "/api/dashboard",
"/api/airports", "/api/airlines",
} {
t.Run(path, func(t *testing.T) {
// No credentials and no tenant: the tenant check fires first.
rec := a.anon(t, http.MethodGet, path)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 400 or 401", rec.Code)
}
})
}
}
// --- session ---------------------------------------------------------------
func TestSessionDescribesTheCaller(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}](t, rec)
if got.User.Email != a.Agent.Email {
t.Errorf("user = %s, want %s", got.User.Email, a.Agent.Email)
}
if got.Tenant.Slug != a.Tenant.Slug {
t.Errorf("tenant = %s, want %s", got.Tenant.Slug, a.Tenant.Slug)
}
}
func TestSessionNeverReturnsThePasswordHash(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
if bytes.Contains(rec.Body.Bytes(), []byte("password")) {
t.Errorf("session response mentions a password field: %s", rec.Body)
}
}
// --- flights ---------------------------------------------------------------
func TestSearchFlightsReturnsAPage(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
rec := a.do(t, http.MethodGet, "/api/flights?origin=JFK&destination=LHR", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
Items []models.Flight `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}](t, rec)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("want one flight, got %d (total %d)", len(got.Items), got.Total)
}
if got.Limit != 25 {
t.Errorf("default limit = %d, want 25", got.Limit)
}
if got.Items[0].Airline.IATACode == "" || got.Items[0].Origin.City == "" {
t.Error("flight should arrive with its airline and airports expanded")
}
}
func TestSearchFlightsValidatesInput(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"bad date", "/api/flights?date=12-09-2026", http.StatusBadRequest},
{"bad cabin", "/api/flights?cabin=luxury", http.StatusBadRequest},
{"good cabin", "/api/flights?cabin=business", http.StatusOK},
{"absurd limit is clamped", "/api/flights?limit=99999", http.StatusOK},
{"negative offset falls back", "/api/flights?offset=-5", http.StatusOK},
{"non-numeric passengers falls back", "/api/flights?passengers=lots", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodGet, tc.path, nil)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.want, rec.Body)
}
})
}
}
func TestGetFlightRejectsAMalformedID(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights/not-a-uuid", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestGetFlightAcrossTenantsIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{})
rec := a.do(t, http.MethodGet, "/api/flights/"+theirFlight.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
}
// --- bookings --------------------------------------------------------------
func newBooking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: "Test", LastName: "Traveller",
})
}
return req
}
func TestCreateBookingReturnsCreated(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 300})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
requireStatus(t, rec, http.StatusCreated)
got := decode[models.Booking](t, rec)
if got.TotalAmount != 600 {
t.Errorf("TotalAmount = %v, want 600", got.TotalAmount)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passengers, want 2", len(got.Passengers))
}
if left := a.SeatsAvailable(t, flight.ID); left != 18 {
t.Errorf("seats = %d, want 18", left)
}
}
func TestCreateBookingValidation(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
noName := newBooking(flight.ID, 1)
noName.Passengers[0].FirstName = " "
tooMany := newBooking(flight.ID, 10)
cases := []struct {
name string
body models.CreateBookingRequest
}{
{"missing flight id", models.CreateBookingRequest{
ContactEmail: "a@b.co",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"missing contact email", models.CreateBookingRequest{
FlightID: flight.ID,
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"malformed contact email", models.CreateBookingRequest{
FlightID: flight.ID,
ContactEmail: "definitely-not-an-email",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"no passengers", models.CreateBookingRequest{
FlightID: flight.ID, ContactEmail: "a@b.co",
}},
{"blank passenger name", noName},
{"more than nine passengers", tooMany},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodPost, "/api/bookings", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body: %s)", rec.Code, rec.Body)
}
})
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("rejected bookings consumed seats: %d left, want 20", left)
}
}
func TestCreateBookingRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
// A client trying to name its own tenant must not be quietly ignored.
body := map[string]any{
"flightId": flight.ID,
"contactEmail": "ops@example.com",
"tenantId": uuid.New(),
"passengers": []map[string]string{{"firstName": "A", "lastName": "B"}},
}
rec := a.do(t, http.MethodPost, "/api/bookings", body)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingRejectsMalformedJSON(t *testing.T) {
a := newAPI(t)
req := httptest.NewRequest(http.MethodPost, "/api/bookings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte(a.Agent.Email+":"+testsupport.Password)))
req.Header.Set(middleware.TenantHeader, a.Tenant.Slug)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingOverCapacityIsConflict(t *testing.T) {
a := newAPI(t)
two := 2
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 3))
requireStatus(t, rec, http.StatusConflict)
}
func TestCreateBookingOnAnotherTenantsFlightIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{Seats: 50})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(theirFlight.ID, 1))
requireStatus(t, rec, http.StatusNotFound)
}
func TestBookingLifecycleOverHTTP(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 100})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2)))
fetched := decode[models.Booking](t, a.do(t, http.MethodGet, "/api/bookings/"+created.ID.String(), nil))
if fetched.BookingReference != created.BookingReference {
t.Errorf("fetched a different booking: %s vs %s", fetched.BookingReference, created.BookingReference)
}
listed := decode[struct {
Items []models.Booking `json:"items"`
Total int `json:"total"`
}](t, a.do(t, http.MethodGet, "/api/bookings", nil))
if listed.Total != 1 {
t.Errorf("list total = %d, want 1", listed.Total)
}
cancelled := decode[models.Booking](t,
a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil))
if cancelled.Status != "cancelled" {
t.Errorf("status = %q, want cancelled", cancelled.Status)
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("seats after cancel = %d, want 20", left)
}
// A second cancel finds nothing to cancel.
again := a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil)
requireStatus(t, again, http.StatusNotFound)
}
func TestListBookingsRejectsAnUnknownStatus(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/bookings?status=pending", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestBookingsAreInvisibleToOtherTenants(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 1)))
// A second agency, with valid credentials of its own, must see nothing.
other := testsupport.Setup(t)
rec := a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings/"+created.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
list := decode[struct {
Total int `json:"total"`
}](t, a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings", nil))
if list.Total != 0 {
t.Errorf("other tenant sees %d bookings, want 0", list.Total)
}
}
// --- dashboard and reference ----------------------------------------------
func TestDashboardSummarisesTheTenant(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 250})
a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
rec := a.do(t, http.MethodGet, "/api/dashboard", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[models.DashboardStats](t, rec)
if got.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", got.ConfirmedBookings)
}
if got.TotalRevenue != 500 {
t.Errorf("TotalRevenue = %v, want 500", got.TotalRevenue)
}
if len(got.RecentBookings) != 1 {
t.Errorf("RecentBookings = %d, want 1", len(got.RecentBookings))
}
}
func TestReferenceListsAreScopedToTheTenantNetwork(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
airports := decode[[]models.Airport](t, a.do(t, http.MethodGet, "/api/airports", nil))
if len(airports) != 2 {
t.Errorf("got %d airports, want the 2 this tenant flies", len(airports))
}
airlines := decode[[]models.Airline](t, a.do(t, http.MethodGet, "/api/airlines", nil))
if len(airlines) != 1 {
t.Errorf("got %d airlines, want 1", len(airlines))
}
}
// --- routing ---------------------------------------------------------------
func TestUnknownRouteAndWrongMethod(t *testing.T) {
a := newAPI(t)
t.Run("unknown path", func(t *testing.T) {
rec := a.do(t, http.MethodGet, "/api/nonexistent", nil)
requireStatus(t, rec, http.StatusNotFound)
})
t.Run("wrong method", func(t *testing.T) {
rec := a.do(t, http.MethodDelete, "/api/flights", nil)
requireStatus(t, rec, http.StatusMethodNotAllowed)
})
}
func TestEveryJSONResponseCarriesItsContentType(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights", nil)
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListTenants backs the agency picker on the sign-in screen. It is
// unauthenticated by necessity -- the client must choose a tenant before it
// can present credentials -- so it returns nothing but slugs and display
// names.
func (h *Handler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.Repo.ListTenants(r.Context())
if err != nil {
h.fail(w, err, "list tenants")
return
}
type publicTenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
items := make([]publicTenant, 0, len(tenants))
for _, t := range tenants {
items = append(items, publicTenant{Slug: t.Slug, Name: t.Name})
}
writeJSON(w, http.StatusOK, items)
}
// Session echoes the authenticated caller back to the client. The Angular app
// calls it once at sign-in to validate the credentials it just collected, and
// again on reload to confirm the stored ones still work.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}{User: id.User, Tenant: id.Tenant})
}
+55
View File
@@ -0,0 +1,55 @@
// Package httpapi assembles the HTTP handler tree.
//
// It lives outside package main so tests can drive the exact same routing,
// middleware order and auth rules the server runs in production, rather than a
// hand-rolled approximation of them.
package httpapi
import (
"log/slog"
"net/http"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// New builds the full handler.
//
// Everything under /api/ requires Basic auth plus a tenant, except the two
// endpoints registered explicitly on the root mux: health, and the tenant list
// the sign-in screen needs before any credentials exist. More specific
// patterns win in Go's mux, so those two bypass the authenticated subtree.
func New(
h *handlers.Handler,
repo *repository.Repository,
logger *slog.Logger,
corsOrigins []string,
) http.Handler {
protected := http.NewServeMux()
protected.HandleFunc("GET /api/session", h.Session)
protected.HandleFunc("GET /api/flights", h.SearchFlights)
protected.HandleFunc("GET /api/flights/{id}", h.GetFlight)
protected.HandleFunc("GET /api/airports", h.ListAirports)
protected.HandleFunc("GET /api/airlines", h.ListAirlines)
protected.HandleFunc("GET /api/bookings", h.ListBookings)
protected.HandleFunc("POST /api/bookings", h.CreateBooking)
protected.HandleFunc("GET /api/bookings/{id}", h.GetBooking)
protected.HandleFunc("POST /api/bookings/{id}/cancel", h.CancelBooking)
protected.HandleFunc("GET /api/dashboard", h.Dashboard)
root := http.NewServeMux()
root.HandleFunc("GET /api/health", h.Health)
root.HandleFunc("GET /api/tenants", h.ListTenants)
root.Handle("/api/", middleware.BasicAuth(repo, logger)(protected))
return middleware.Chain(root,
middleware.Recoverer(logger),
middleware.Logger(logger),
middleware.CORS(corsOrigins),
)
}
+223
View File
@@ -0,0 +1,223 @@
// Package middleware contains the HTTP layer that runs before handlers:
// panic recovery, logging, CORS, and Basic authentication with tenant
// resolution.
package middleware
import (
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// TenantHeader names the header an SPA uses to say which agency it is signing
// in to. A subdomain is used as a fallback so the same build works when
// deployed as skyline.example.com.
const TenantHeader = "X-Tenant"
// Recoverer turns a panic into a 500 instead of killing the connection.
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic serving request",
"method", r.Method, "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
// statusRecorder remembers the status code so the logger can report it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// Logger writes one line per request.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"tenant", r.Header.Get(TenantHeader),
"duration", time.Since(start).String(),
)
})
}
}
// CORS answers preflights and echoes back only origins on the allow list.
func CORS(allowed []string) func(http.Handler) http.Handler {
allowSet := make(map[string]bool, len(allowed))
for _, o := range allowed {
allowSet[strings.TrimSuffix(o, "/")] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSuffix(r.Header.Get("Origin"), "/")
if origin != "" && allowSet[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+TenantHeader)
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Max-Age", "600")
// Responses differ per origin, so caches must key on it.
h.Add("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// BasicAuth authenticates the caller and pins them to one tenant.
//
// Both halves matter: the password is checked against the user row *inside*
// the named tenant, so identical credentials at another agency will not
// authenticate here. The resolved tenant goes into the request context and is
// the only tenant id the handlers below ever use.
func BasicAuth(repo *repository.Repository, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slug := tenantSlug(r)
if slug == "" {
writeError(w, http.StatusBadRequest,
"missing tenant: send the "+TenantHeader+" header")
return
}
email, password, ok := r.BasicAuth()
if !ok {
unauthorized(w)
return
}
ctx := r.Context()
tenant, err := repo.TenantBySlug(ctx, slug)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Same response as a bad password: do not let an
// unauthenticated caller enumerate which tenants exist.
unauthorized(w)
return
}
logger.Error("tenant lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
user, err := repo.UserByEmail(ctx, tenant.ID, email)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Hash a throwaway value so a missing user costs the same
// time as a wrong password, which stops an attacker
// learning valid addresses from response timing.
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
unauthorized(w)
return
}
logger.Error("user lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
unauthorized(w)
return
}
// Defence in depth: the user row must belong to the tenant we
// resolved. The query already guarantees it; this catches a future
// refactor that breaks the invariant.
if subtle.ConstantTimeCompare(user.TenantID[:], tenant.ID[:]) != 1 {
unauthorized(w)
return
}
ctx = auth.WithIdentity(ctx, auth.Identity{User: user, Tenant: tenant})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAdmin gates routes that only a tenant administrator may call.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, err := auth.FromContext(r.Context())
if err != nil || !id.IsAdmin() {
writeError(w, http.StatusForbidden, "administrator role required")
return
}
next.ServeHTTP(w, r)
})
}
// dummyHash is a valid bcrypt hash of a value nobody uses, for timing parity.
var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
// tenantSlug reads the tenant from the header, falling back to the first label
// of the Host name for subdomain-per-tenant deployments.
func tenantSlug(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get(TenantHeader)); v != "" {
return strings.ToLower(v)
}
host := r.Host
if h, _, found := strings.Cut(host, ":"); found {
host = h
}
labels := strings.Split(host, ".")
if len(labels) >= 3 && labels[0] != "www" {
return strings.ToLower(labels[0])
}
return ""
}
// unauthorized replies 401 without a WWW-Authenticate header on purpose: that
// header makes browsers open their own credential dialog, which would fight
// with the Angular sign-in screen.
func unauthorized(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "invalid credentials for this tenant")
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// Chain applies middleware so the first argument is the outermost layer.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
@@ -0,0 +1,342 @@
package middleware_test
import (
"encoding/base64"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func basic(email, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+password))
}
// guarded wraps a handler that records the identity the middleware resolved.
func guarded(f *testsupport.Fixture, seen *auth.Identity) http.Handler {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id, err := auth.FromContext(r.Context()); err == nil && seen != nil {
*seen = id
}
w.WriteHeader(http.StatusOK)
})
return middleware.BasicAuth(f.Repo, quietLogger())(inner)
}
func TestBasicAuthAcceptsValidCredentials(t *testing.T) {
f := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body)
}
if got.User.ID != f.Agent.ID {
t.Errorf("resolved user %s, want %s", got.User.ID, f.Agent.ID)
}
if got.Tenant.ID != f.Tenant.ID {
t.Errorf("resolved tenant %s, want %s", got.Tenant.ID, f.Tenant.ID)
}
}
func TestBasicAuthIsCaseInsensitiveOnEmail(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(strings.ToUpper(f.Agent.Email), testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestBasicAuthRejections(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
cases := []struct {
name string
authHeader string
tenant string
wantStatus int
}{
{
name: "no credentials at all",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong password",
authHeader: basic(f.Agent.Email, "not-the-password"),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown user",
authHeader: basic("nobody@nowhere.test", testsupport.Password),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "no-such-agency",
wantStatus: http.StatusUnauthorized,
},
{
// The central rule: right password, wrong agency.
name: "valid credentials against another tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: other.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "missing tenant header",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "",
wantStatus: http.StatusBadRequest,
},
{
name: "malformed authorization header",
authHeader: "Bearer some.jwt.token",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
if tc.tenant != "" {
req.Header.Set(middleware.TenantHeader, tc.tenant)
}
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body)
}
})
}
}
func TestBasicAuthRefusesSuspendedTenant(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 for a suspended tenant", rec.Code)
}
}
// Sending WWW-Authenticate would make the browser pop its own login dialog over
// the Angular sign-in screen, so its absence is a deliberate contract.
func TestUnauthorizedDoesNotTriggerTheBrowserDialog(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, "wrong"))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if got := rec.Header().Get("WWW-Authenticate"); got != "" {
t.Errorf("WWW-Authenticate = %q, want it absent", got)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
// Deployments that give each agency a subdomain need no client changes.
func TestTenantFallsBackToSubdomain(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Host = f.Tenant.Slug + ".travelhub.example.com:8080"
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 resolving the tenant from the subdomain", rec.Code)
}
}
func TestTenantHeaderBeatsSubdomain(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
req.Host = other.Tenant.Slug + ".travelhub.example.com"
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got.Tenant.ID != f.Tenant.ID {
t.Error("the subdomain overrode the explicit tenant header")
}
}
func TestRequireAdmin(t *testing.T) {
f := testsupport.Setup(t)
adminOnly := middleware.BasicAuth(f.Repo, quietLogger())(
middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})),
)
cases := []struct {
name string
email string
want int
}{
{"admin passes", f.Admin.Email, http.StatusOK},
{"agent is refused", f.Agent.Email, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
req.Header.Set("Authorization", basic(tc.email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
adminOnly.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
func TestCORS(t *testing.T) {
allowed := []string{"http://localhost:4200", "https://app.example.com/"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware.CORS(allowed)(ok)
t.Run("echoes an allowed origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:4200" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") {
t.Errorf("Vary = %q, want it to include Origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, middleware.TenantHeader) {
t.Errorf("Allow-Headers = %q, want it to include %s", got, middleware.TenantHeader)
}
})
t.Run("tolerates a trailing slash in configuration", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Allow-Origin = %q, want the origin echoed", got)
}
})
t.Run("ignores an origin that is not on the list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want it absent", got)
}
})
t.Run("answers preflight without reaching the handler", func(t *testing.T) {
reached := false
h := middleware.CORS(allowed)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
if reached {
t.Error("preflight should short-circuit before the handler")
}
})
}
func TestRecovererTurnsPanicIntoFiveHundred(t *testing.T) {
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("something went very wrong")
})
handler := middleware.Recoverer(quietLogger())(boom)
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
rec := httptest.NewRecorder()
// The point is that this call returns rather than taking the process down.
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if !strings.Contains(rec.Body.String(), "internal server error") {
t.Errorf("body = %s, want a JSON error", rec.Body)
}
}
+143
View File
@@ -0,0 +1,143 @@
// Package models holds the API-facing representations of TravelHub data.
// JSON field names are camelCase so the Angular client can consume them
// without a translation layer.
package models
import (
"time"
"github.com/google/uuid"
)
// Tenant is one travel agency using the platform.
type Tenant struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
// User belongs to exactly one tenant. Email is unique per tenant, not globally,
// so the same person can hold accounts at several agencies.
type User struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
// PasswordHash never leaves the server.
PasswordHash string `json:"-"`
}
// Airport is global reference data, shared by every tenant.
type Airport struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
Timezone string `json:"timezone"`
}
// Airline is global reference data, shared by every tenant.
type Airline struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
LogoURL string `json:"logoUrl,omitempty"`
}
// Flight is tenant-scoped inventory: each agency loads its own fares, so the
// same physical route carries a different price per tenant.
type Flight struct {
ID uuid.UUID `json:"id"`
FlightNumber string `json:"flightNumber"`
Airline Airline `json:"airline"`
Origin Airport `json:"origin"`
Destination Airport `json:"destination"`
DepartureTime time.Time `json:"departureTime"`
ArrivalTime time.Time `json:"arrivalTime"`
DurationMinutes int `json:"durationMinutes"`
Aircraft string `json:"aircraft"`
CabinClass string `json:"cabinClass"`
BasePrice float64 `json:"basePrice"`
Currency string `json:"currency"`
SeatsTotal int `json:"seatsTotal"`
SeatsAvailable int `json:"seatsAvailable"`
Status string `json:"status"`
}
// Passenger is one traveller on a booking.
type Passenger struct {
ID uuid.UUID `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth *time.Time `json:"dateOfBirth,omitempty"`
PassportNumber string `json:"passportNumber,omitempty"`
SeatNumber string `json:"seatNumber,omitempty"`
}
// Booking is a confirmed sale against a flight, owned by one tenant.
type Booking struct {
ID uuid.UUID `json:"id"`
BookingReference string `json:"bookingReference"`
Flight Flight `json:"flight"`
Passengers []Passenger `json:"passengers"`
PassengerCount int `json:"passengerCount"`
TotalAmount float64 `json:"totalAmount"`
Currency string `json:"currency"`
Status string `json:"status"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone,omitempty"`
BookedBy string `json:"bookedBy"`
CreatedAt time.Time `json:"createdAt"`
}
// FlightSearch carries the filters accepted by GET /api/flights.
type FlightSearch struct {
Origin string // IATA code, optional
Destination string // IATA code, optional
DepartDate *time.Time // matches the whole calendar day, optional
CabinClass string // optional
Passengers int // minimum seats that must still be available
SortBy string // price | duration | departure
Limit int
Offset int
}
// PassengerInput is one traveller as supplied by the client. Dates arrive as
// YYYY-MM-DD strings and are parsed when the booking is written.
type PassengerInput struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth string `json:"dateOfBirth"`
PassportNumber string `json:"passportNumber"`
}
// CreateBookingRequest is the POST /api/bookings payload.
type CreateBookingRequest struct {
FlightID uuid.UUID `json:"flightId"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone"`
Passengers []PassengerInput `json:"passengers"`
}
// DashboardStats powers the agency overview screen.
type DashboardStats struct {
TotalBookings int `json:"totalBookings"`
ConfirmedBookings int `json:"confirmedBookings"`
CancelledBookings int `json:"cancelledBookings"`
TotalRevenue float64 `json:"totalRevenue"`
Currency string `json:"currency"`
FlightsInStock int `json:"flightsInStock"`
SeatsSold int `json:"seatsSold"`
RecentBookings []Booking `json:"recentBookings"`
TopRoutes []struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
} `json:"topRoutes"`
}
+330
View File
@@ -0,0 +1,330 @@
package repository
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// bookingSelect projects a booking together with its flight and the agent who
// sold it.
const bookingSelect = `
SELECT b.id, b.booking_reference, b.passenger_count, b.total_amount,
b.currency, b.status, b.contact_email, b.contact_phone, b.created_at,
u.full_name,
f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM bookings b
JOIN users u ON u.id = b.user_id
JOIN flights f ON f.id = b.flight_id
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
func scanBooking(row pgx.Row) (models.Booking, error) {
var b models.Booking
f := &b.Flight
err := row.Scan(
&b.ID, &b.BookingReference, &b.PassengerCount, &b.TotalAmount,
&b.Currency, &b.Status, &b.ContactEmail, &b.ContactPhone, &b.CreatedAt,
&b.BookedBy,
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Booking{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
b.Passengers = []models.Passenger{}
return b, nil
}
// pnrAlphabet omits I, O, 0 and 1 because agents read references aloud.
const pnrAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func newPNR() (string, error) {
out := make([]byte, 6)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pnrAlphabet))))
if err != nil {
return "", err
}
out[i] = pnrAlphabet[n.Int64()]
}
return string(out), nil
}
// CreateBooking sells seats on a flight.
//
// The whole thing 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 at the same moment cannot oversell: the second waits for the
// first to commit and then sees the decremented count.
func (r *Repository) CreateBooking(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateBookingRequest) (models.Booking, error) {
if len(req.Passengers) == 0 {
return models.Booking{}, errors.New("at least one passenger is required")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
price float64
currency string
available int
)
const lockSQL = `
SELECT base_price, currency, seats_available
FROM flights
WHERE tenant_id = $1 AND id = $2 AND status <> 'cancelled'
FOR UPDATE`
err = tx.QueryRow(ctx, lockSQL, tenantID, req.FlightID).Scan(&price, &currency, &available)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("lock flight: %w", err)
}
count := len(req.Passengers)
if available < count {
return models.Booking{}, ErrNoSeats
}
const decSQL = `UPDATE flights SET seats_available = seats_available - $3
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, decSQL, tenantID, req.FlightID, count); err != nil {
return models.Booking{}, fmt.Errorf("reserve seats: %w", err)
}
const insertSQL = `
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency,
contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
// The PNR is unique per tenant; on the rare collision, draw again.
var bookingID uuid.UUID
for attempt := 0; attempt < 5; attempt++ {
pnr, perr := newPNR()
if perr != nil {
return models.Booking{}, perr
}
err = tx.QueryRow(ctx, insertSQL,
tenantID, userID, req.FlightID, pnr, count,
price*float64(count), currency, req.ContactEmail, req.ContactPhone,
).Scan(&bookingID)
if err == nil {
break
}
if !isUniqueViolation(err) {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
}
if err != nil {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
const paxSQL = `
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
VALUES ($1, $2, $3, $4, $5, $6)`
for _, p := range req.Passengers {
var dob *time.Time
if p.DateOfBirth != "" {
if parsed, perr := time.Parse("2006-01-02", p.DateOfBirth); perr == nil {
dob = &parsed
}
}
if _, err := tx.Exec(ctx, paxSQL, tenantID, bookingID,
p.FirstName, p.LastName, dob, p.PassportNumber); err != nil {
return models.Booking{}, fmt.Errorf("insert passenger: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// BookingByID returns one booking with its passengers.
func (r *Repository) BookingByID(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
q := bookingSelect + ` WHERE b.tenant_id = $1 AND b.id = $2`
b, err := scanBooking(r.pool.QueryRow(ctx, q, tenantID, bookingID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, err
}
pax, err := r.passengersFor(ctx, tenantID, []uuid.UUID{b.ID})
if err != nil {
return models.Booking{}, err
}
if list, ok := pax[b.ID]; ok {
b.Passengers = list
}
return b, nil
}
// ListBookings returns a tenant's bookings, newest first, with passengers
// attached. Passengers are fetched in one extra query rather than one per
// booking.
func (r *Repository) ListBookings(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]models.Booking, int, error) {
where := "WHERE b.tenant_id = $1"
args := []any{tenantID}
if status != "" {
args = append(args, status)
where += fmt.Sprintf(" AND b.status = $%d", len(args))
}
var total int
if err := r.pool.QueryRow(ctx, "SELECT count(*) FROM bookings b "+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count bookings: %w", err)
}
if limit <= 0 || limit > 100 {
limit = 25
}
if offset < 0 {
offset = 0
}
q := fmt.Sprintf("%s %s ORDER BY b.created_at DESC LIMIT $%d OFFSET $%d",
bookingSelect, where, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("list bookings: %w", err)
}
defer rows.Close()
bookings := []models.Booking{}
ids := []uuid.UUID{}
for rows.Next() {
b, err := scanBooking(rows)
if err != nil {
return nil, 0, err
}
bookings = append(bookings, b)
ids = append(ids, b.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
pax, err := r.passengersFor(ctx, tenantID, ids)
if err != nil {
return nil, 0, err
}
for i := range bookings {
if list, ok := pax[bookings[i].ID]; ok {
bookings[i].Passengers = list
}
}
}
return bookings, total, nil
}
// CancelBooking voids a booking and returns its seats to inventory.
func (r *Repository) CancelBooking(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
flightID uuid.UUID
count int
)
// Filtering on status here makes the cancel idempotent-safe: a second
// cancel matches no row and returns ErrNotFound rather than crediting the
// seats twice.
const claimSQL = `
UPDATE bookings SET status = 'cancelled'
WHERE tenant_id = $1 AND id = $2 AND status = 'confirmed'
RETURNING flight_id, passenger_count`
err = tx.QueryRow(ctx, claimSQL, tenantID, bookingID).Scan(&flightID, &count)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("cancel booking: %w", err)
}
const restoreSQL = `
UPDATE flights
SET seats_available = LEAST(seats_total, seats_available + $3)
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, restoreSQL, tenantID, flightID, count); err != nil {
return models.Booking{}, fmt.Errorf("restore seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// passengersFor loads passengers for several bookings at once, keyed by
// booking id.
func (r *Repository) passengersFor(ctx context.Context, tenantID uuid.UUID, bookingIDs []uuid.UUID) (map[uuid.UUID][]models.Passenger, error) {
const q = `
SELECT booking_id, id, first_name, last_name, date_of_birth,
passport_number, seat_number
FROM passengers
WHERE tenant_id = $1 AND booking_id = ANY($2)
ORDER BY last_name, first_name`
rows, err := r.pool.Query(ctx, q, tenantID, bookingIDs)
if err != nil {
return nil, fmt.Errorf("list passengers: %w", err)
}
defer rows.Close()
out := map[uuid.UUID][]models.Passenger{}
for rows.Next() {
var (
bookingID uuid.UUID
p models.Passenger
)
if err := rows.Scan(&bookingID, &p.ID, &p.FirstName, &p.LastName,
&p.DateOfBirth, &p.PassportNumber, &p.SeatNumber); err != nil {
return nil, err
}
out[bookingID] = append(out[bookingID], p)
}
return out, rows.Err()
}
@@ -0,0 +1,415 @@
package repository_test
import (
"context"
"sync"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// booking builds a request for the given flight with n generated travellers.
func booking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
names := []string{"Ada", "Ben", "Cleo", "Dara", "Emil", "Fay", "Gus", "Hana", "Ivo"}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: names[i%len(names)],
LastName: "Traveller",
DateOfBirth: "1990-01-15",
PassportNumber: "P000000" + string(rune('1'+i)),
})
}
return req
}
func TestCreateBookingSellsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 250})
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if got.PassengerCount != 2 {
t.Errorf("PassengerCount = %d, want 2", got.PassengerCount)
}
if got.TotalAmount != 500 {
t.Errorf("TotalAmount = %v, want 500 (250 x 2)", got.TotalAmount)
}
if got.Status != "confirmed" {
t.Errorf("Status = %q, want confirmed", got.Status)
}
if len(got.BookingReference) != 6 {
t.Errorf("booking reference %q should be 6 characters", got.BookingReference)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passenger rows, want 2", len(got.Passengers))
}
if got.BookedBy != f.Agent.FullName {
t.Errorf("BookedBy = %q, want %q", got.BookedBy, f.Agent.FullName)
}
if left := f.SeatsAvailable(t, flight.ID); left != 48 {
t.Errorf("seats_available = %d, want 48", left)
}
}
func TestCreateBookingStoresPassengerDetail(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
p := got.Passengers[0]
if p.FirstName != "Ada" || p.LastName != "Traveller" {
t.Errorf("passenger name = %s %s, want Ada Traveller", p.FirstName, p.LastName)
}
if p.DateOfBirth == nil {
t.Fatal("date of birth was not parsed and stored")
}
if got := p.DateOfBirth.Format("2006-01-02"); got != "1990-01-15" {
t.Errorf("date of birth = %s, want 1990-01-15", got)
}
}
func TestCreateBookingRejectsBlankDateOfBirth(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := booking(flight.ID, 1)
req.Passengers[0].DateOfBirth = "" // optional field left empty by the UI
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req)
if err != nil {
t.Fatalf("an empty date of birth should be accepted: %v", err)
}
if got.Passengers[0].DateOfBirth != nil {
t.Error("want nil date of birth when none was supplied")
}
}
func TestCreateBookingRefusesMoreSeatsThanRemain(t *testing.T) {
f := testsupport.Setup(t)
two := 2
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != repository.ErrNoSeats {
t.Fatalf("want ErrNoSeats, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 2 {
t.Errorf("a refused booking must not consume seats: got %d, want 2", left)
}
}
func TestCreateBookingRequiresAPassenger(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := models.CreateBookingRequest{FlightID: flight.ID, ContactEmail: "a@b.co"}
if _, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req); err == nil {
t.Fatal("want an error for a booking with no passengers")
}
}
func TestCreateBookingCannotReachAnotherTenantsFlight(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 100})
_, err := mine.Repo.CreateBooking(context.Background(), mine.Tenant.ID, mine.Agent.ID,
booking(other.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound booking across tenants, got %v", err)
}
if left := theirs.SeatsAvailable(t, other.ID); left != 100 {
t.Errorf("the other tenant's inventory was touched: %d seats left, want 100", left)
}
}
func TestCreateBookingRefusesCancelledFlight(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{Status: "cancelled"})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a cancelled flight, got %v", err)
}
}
// The row lock is the only thing standing between two simultaneous sales and
// an oversold flight, so assert on it directly.
func TestCreateBookingDoesNotOversellUnderConcurrency(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
const contenders = 8
one := 1
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 20, Available: &one})
var (
wg sync.WaitGroup
mu sync.Mutex
sold int
refused int
unexpect []error
)
start := make(chan struct{})
for i := 0; i < contenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start // release everyone at once
_, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
sold++
case err == repository.ErrNoSeats:
refused++
default:
unexpect = append(unexpect, err)
}
}()
}
close(start)
wg.Wait()
if len(unexpect) > 0 {
t.Fatalf("unexpected errors: %v", unexpect)
}
if sold != 1 {
t.Errorf("exactly one booking should succeed, got %d", sold)
}
if refused != contenders-1 {
t.Errorf("got %d refusals, want %d", refused, contenders-1)
}
if left := f.SeatsAvailable(t, flight.ID); left != 0 {
t.Errorf("seats_available = %d, want 0", left)
}
}
func TestCancelBookingReturnsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 27 {
t.Fatalf("seats after booking = %d, want 27", left)
}
got, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("cancel: %v", err)
}
if got.Status != "cancelled" {
t.Errorf("Status = %q, want cancelled", got.Status)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats after cancel = %d, want 30", left)
}
}
// Cancelling twice must not credit the seats twice.
func TestCancelBookingIsNotRepeatable(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != nil {
t.Fatalf("first cancel: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("second cancel should be ErrNotFound, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats = %d, want 30 -- a second cancel must not credit again", left)
}
}
func TestCancelBookingAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 10})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
still, err := theirs.Repo.BookingByID(ctx, theirs.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if still.Status != "confirmed" {
t.Error("another tenant managed to cancel this booking")
}
}
func TestListBookingsIsolatesTenantsAndFiltersStatus(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50})
keep, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
drop, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, drop.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 1)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
all, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "", 25, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 2 || len(all) != 2 {
t.Fatalf("want this tenant's 2 bookings, got %d (total %d)", len(all), total)
}
confirmed, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "confirmed", 25, 0)
if err != nil {
t.Fatalf("list confirmed: %v", err)
}
if total != 1 || len(confirmed) != 1 || confirmed[0].ID != keep.ID {
t.Fatalf("status filter did not isolate the confirmed booking (got %d)", len(confirmed))
}
if len(confirmed[0].Passengers) != 1 {
t.Error("list should attach passengers to each booking")
}
}
func TestBookingByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.BookingByID(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestBookingReferencesAreUniqueWithinATenant(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 100})
seen := map[string]bool{}
for i := 0; i < 25; i++ {
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create %d: %v", i, err)
}
if seen[got.BookingReference] {
t.Fatalf("duplicate booking reference %q", got.BookingReference)
}
seen[got.BookingReference] = true
}
}
func TestDashboardStatsCountOnlyThisTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 200})
if _, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 2)); err != nil {
t.Fatalf("create: %v", err)
}
cancelled, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, cancelled.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 999})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 4)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
stats, err := mine.Repo.DashboardStats(ctx, mine.Tenant.ID)
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.TotalBookings != 2 {
t.Errorf("TotalBookings = %d, want 2", stats.TotalBookings)
}
if stats.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", stats.ConfirmedBookings)
}
if stats.CancelledBookings != 1 {
t.Errorf("CancelledBookings = %d, want 1", stats.CancelledBookings)
}
// Revenue counts confirmed sales only: 200 x 2.
if stats.TotalRevenue != 400 {
t.Errorf("TotalRevenue = %v, want 400", stats.TotalRevenue)
}
if stats.SeatsSold != 2 {
t.Errorf("SeatsSold = %d, want 2", stats.SeatsSold)
}
if stats.FlightsInStock != 1 {
t.Errorf("FlightsInStock = %d, want 1", stats.FlightsInStock)
}
if len(stats.TopRoutes) != 1 || stats.TopRoutes[0].Revenue != 400 {
t.Errorf("TopRoutes did not summarise this tenant's sales: %+v", stats.TopRoutes)
}
}
+149
View File
@@ -0,0 +1,149 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// flightSelect is the projection shared by every flight read, joining the two
// global reference tables so the API can return nested airline/airport objects.
const flightSelect = `
SELECT f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM flights f
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
// scanFlight reads one row of flightSelect.
func scanFlight(row pgx.Row) (models.Flight, error) {
var f models.Flight
err := row.Scan(
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Flight{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
return f, nil
}
// searchFilters turns a FlightSearch into WHERE fragments plus their arguments.
// tenantID is always $1, so every generated query is tenant-scoped by
// construction rather than by the caller remembering to add it.
func searchFilters(tenantID uuid.UUID, s models.FlightSearch) ([]string, []any) {
where := []string{"f.tenant_id = $1", "f.status <> 'cancelled'", "f.departure_time > now()"}
args := []any{tenantID}
add := func(clause string, value any) {
args = append(args, value)
where = append(where, fmt.Sprintf(clause, len(args)))
}
// Airports are matched by IATA code through a subquery so the count query
// does not need the joins that flightSelect carries.
if s.Origin != "" {
add("f.origin_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Origin)
}
if s.Destination != "" {
add("f.destination_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Destination)
}
if s.CabinClass != "" {
add("f.cabin_class = $%d", s.CabinClass)
}
if s.Passengers > 0 {
add("f.seats_available >= $%d", s.Passengers)
}
if s.DepartDate != nil {
// Half-open range keeps the composite index on departure_time usable.
args = append(args, *s.DepartDate)
where = append(where, fmt.Sprintf(
"f.departure_time >= $%d AND f.departure_time < ($%d::timestamptz + interval '1 day')",
len(args), len(args)))
}
return where, args
}
// orderClause whitelists sort options; anything unrecognised falls back to
// price so a hostile sortBy value can never reach the SQL text.
func orderClause(sortBy string) string {
switch sortBy {
case "duration":
return "ORDER BY (f.arrival_time - f.departure_time) ASC, f.base_price ASC"
case "departure":
return "ORDER BY f.departure_time ASC, f.base_price ASC"
default:
return "ORDER BY f.base_price ASC, f.departure_time ASC"
}
}
// SearchFlights returns one page of a tenant's inventory plus the total count
// matching the filters.
func (r *Repository) SearchFlights(ctx context.Context, tenantID uuid.UUID, s models.FlightSearch) ([]models.Flight, int, error) {
where, args := searchFilters(tenantID, s)
whereSQL := "WHERE " + strings.Join(where, " AND ")
var total int
countSQL := "SELECT count(*) FROM flights f " + whereSQL
if err := r.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count flights: %w", err)
}
limit := s.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
offset := s.Offset
if offset < 0 {
offset = 0
}
pageArgs := append(append([]any{}, args...), limit, offset)
listSQL := fmt.Sprintf("%s %s %s LIMIT $%d OFFSET $%d",
flightSelect, whereSQL, orderClause(s.SortBy), len(args)+1, len(args)+2)
rows, err := r.pool.Query(ctx, listSQL, pageArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list flights: %w", err)
}
defer rows.Close()
flights := []models.Flight{}
for rows.Next() {
f, err := scanFlight(rows)
if err != nil {
return nil, 0, err
}
flights = append(flights, f)
}
return flights, total, rows.Err()
}
// FlightByID fetches a single flight belonging to the tenant.
func (r *Repository) FlightByID(ctx context.Context, tenantID, flightID uuid.UUID) (models.Flight, error) {
q := flightSelect + ` WHERE f.tenant_id = $1 AND f.id = $2`
f, err := scanFlight(r.pool.QueryRow(ctx, q, tenantID, flightID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Flight{}, ErrNotFound
}
return f, err
}
@@ -0,0 +1,280 @@
package repository_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestSearchFlightsFiltersByRoute(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
want := f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
f.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
got, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{
Origin: "JFK", Destination: "LHR",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("want 1 flight, got %d (total %d)", len(got), total)
}
if got[0].ID != want.ID {
t.Errorf("wrong flight: got %s want %s", got[0].ID, want.ID)
}
}
func TestSearchFlightsLowercaseIATAStillMatches(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
// The repository upper-cases codes in SQL, so a sloppy client still works.
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Origin: "jfk", Destination: "lhr",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 flight for lowercase codes, got %d", len(got))
}
}
// The headline guarantee: one agency's inventory is invisible to another.
func TestSearchFlightsIsolatesTenants(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
mine.NewFlight(t, testsupport.FlightOpts{Number: "MINE1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS2"})
got, total, err := mine.Repo.SearchFlights(ctx, mine.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("tenant should see exactly its own 1 flight, saw %d (total %d)", len(got), total)
}
if got[0].FlightNumber != "MINE1" {
t.Errorf("leaked another tenant's flight: %s", got[0].FlightNumber)
}
}
func TestFlightByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{})
// Even holding a valid id, the wrong tenant must get ErrNotFound -- not a
// permission error, which would confirm the record exists.
_, err := mine.Repo.FlightByID(context.Background(), mine.Tenant.ID, other.ID)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for cross-tenant read, got %v", err)
}
}
func TestFlightByIDUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
_, err := f.Repo.FlightByID(context.Background(), f.Tenant.ID, uuid.New())
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestSearchFlightsHidesDepartedAndCancelled(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "FUTURE", DepartsIn: 24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "PAST", DepartsIn: -24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "GONE", Status: "cancelled"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "FUTURE" {
names := make([]string, len(got))
for i, fl := range got {
names[i] = fl.FlightNumber
}
t.Fatalf("want only FUTURE, got %v", names)
}
}
func TestSearchFlightsRespectsPartySize(t *testing.T) {
f := testsupport.Setup(t)
two := 2
five := 5
f.NewFlight(t, testsupport.FlightOpts{Number: "TIGHT", Seats: 10, Available: &two})
f.NewFlight(t, testsupport.FlightOpts{Number: "ROOMY", Seats: 10, Available: &five})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Passengers: 4,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ROOMY" {
t.Fatalf("a party of 4 should only see ROOMY, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByCabin(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "ECON", Cabin: "economy"})
f.NewFlight(t, testsupport.FlightOpts{Number: "BIZ", Cabin: "business"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
CabinClass: "business",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "BIZ" {
t.Fatalf("want only the business flight, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByDepartureDay(t *testing.T) {
f := testsupport.Setup(t)
// Land both flights at a fixed hour so the test cannot straddle midnight.
tomorrow := time.Now().AddDate(0, 0, 1)
target := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.Local)
f.NewFlight(t, testsupport.FlightOpts{Number: "ONDAY", DepartsIn: time.Until(target)})
f.NewFlight(t, testsupport.FlightOpts{Number: "OFFDAY", DepartsIn: time.Until(target.AddDate(0, 0, 3))})
day := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.Local)
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
DepartDate: &day,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ONDAY" {
t.Fatalf("want only ONDAY for that calendar day, got %d results", len(got))
}
}
func TestSearchFlightsSorting(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
f.NewFlight(t, testsupport.FlightOpts{
Number: "CHEAPSLOW", Price: 100, Duration: 12 * time.Hour, DepartsIn: 72 * time.Hour,
})
f.NewFlight(t, testsupport.FlightOpts{
Number: "DEARFAST", Price: 900, Duration: 2 * time.Hour, DepartsIn: 24 * time.Hour,
})
cases := []struct {
sortBy string
want string
}{
{"price", "CHEAPSLOW"},
{"duration", "DEARFAST"},
{"departure", "DEARFAST"},
{"nonsense-falls-back-to-price", "CHEAPSLOW"},
}
for _, tc := range cases {
t.Run(tc.sortBy, func(t *testing.T) {
got, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{SortBy: tc.sortBy})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) == 0 {
t.Fatal("no results")
}
if got[0].FlightNumber != tc.want {
t.Errorf("sort %q: first result %s, want %s", tc.sortBy, got[0].FlightNumber, tc.want)
}
})
}
}
func TestSearchFlightsPaginates(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
f.NewFlight(t, testsupport.FlightOpts{Price: float64(100 * (i + 1))})
}
first, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 0})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 5 {
t.Errorf("total = %d, want 5", total)
}
if len(first) != 2 {
t.Fatalf("page size = %d, want 2", len(first))
}
second, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 2})
if err != nil {
t.Fatalf("search page 2: %v", err)
}
if len(second) != 2 {
t.Fatalf("second page size = %d, want 2", len(second))
}
if first[0].ID == second[0].ID {
t.Error("second page repeated the first page's first row")
}
}
func TestSearchFlightsComputesDuration(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Duration: 7*time.Hour + 25*time.Minute})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if got[0].DurationMinutes != 445 {
t.Errorf("DurationMinutes = %d, want 445", got[0].DurationMinutes)
}
}
func TestListAirportsCoversOnlyThisTenantsNetwork(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
mine.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
theirs.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
airports, err := mine.Repo.ListAirports(context.Background(), mine.Tenant.ID)
if err != nil {
t.Fatalf("list airports: %v", err)
}
seen := map[string]bool{}
for _, a := range airports {
seen[a.IATACode] = true
}
if !seen["JFK"] || !seen["LHR"] {
t.Errorf("want JFK and LHR in the network, got %v", seen)
}
if seen["LAX"] || seen["DXB"] {
t.Errorf("leaked the other tenant's airports: %v", seen)
}
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// TenantBySlug resolves the tenant a caller is signing in to. Suspended
// tenants are treated as absent so their users cannot authenticate.
func (r *Repository) TenantBySlug(ctx context.Context, slug string) (models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE slug = $1 AND status = 'active'`
var t models.Tenant
err := r.pool.QueryRow(ctx, q, slug).
Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return models.Tenant{}, ErrNotFound
}
return t, err
}
// ListTenants powers the tenant picker on the sign-in screen. It is the only
// unauthenticated read in the service and deliberately exposes nothing beyond
// the slug and display name.
func (r *Repository) ListTenants(ctx context.Context) ([]models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE status = 'active'
ORDER BY name`
rows, err := r.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
tenants := []models.Tenant{}
for rows.Next() {
var t models.Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}
// UserByEmail looks up a login within one tenant. The tenant filter is what
// stops credentials from one agency authenticating against another.
func (r *Repository) UserByEmail(ctx context.Context, tenantID uuid.UUID, email string) (models.User, error) {
const q = `
SELECT id, tenant_id, email, password_hash, full_name, role, created_at
FROM users
WHERE tenant_id = $1 AND lower(email) = lower($2)`
var u models.User
err := r.pool.QueryRow(ctx, q, tenantID, email).Scan(
&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return models.User{}, ErrNotFound
}
return u, err
}
@@ -0,0 +1,117 @@
package repository_test
import (
"context"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestTenantBySlug(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Tenant.ID {
t.Errorf("got tenant %s, want %s", got.ID, f.Tenant.ID)
}
}
func TestTenantBySlugUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
if _, err := f.Repo.TenantBySlug(context.Background(), "no-such-agency"); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
// A suspended tenant must be indistinguishable from a missing one, so its
// users cannot sign in.
func TestTenantBySlugSuspendedIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
if _, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a suspended tenant, got %v", err)
}
}
func TestUserByEmailIsCaseInsensitive(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.UserByEmail(context.Background(), f.Tenant.ID, strings.ToUpper(f.Agent.Email))
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Agent.ID {
t.Errorf("got user %s, want %s", got.ID, f.Agent.ID)
}
}
// The tenant filter on the user lookup is what stops one agency's credentials
// from authenticating at another.
func TestUserByEmailIsScopedToTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
_, err := mine.Repo.UserByEmail(context.Background(), mine.Tenant.ID, theirs.Agent.Email)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound looking up another tenant's user, got %v", err)
}
}
// The same address may hold an account at more than one agency; each must
// resolve to its own row.
func TestSameEmailCanExistInTwoTenants(t *testing.T) {
a := testsupport.Setup(t)
b := testsupport.Setup(t)
const shared = "traveller@example.com"
inA := a.NewUser(t, shared, "agent")
inB := b.NewUser(t, shared, "admin")
gotA, err := a.Repo.UserByEmail(context.Background(), a.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in A: %v", err)
}
gotB, err := b.Repo.UserByEmail(context.Background(), b.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in B: %v", err)
}
if gotA.ID != inA.ID || gotB.ID != inB.ID {
t.Error("shared email resolved to the wrong tenant's user")
}
if gotA.ID == gotB.ID {
t.Error("the two tenants collapsed onto one user row")
}
if gotA.Role != "agent" || gotB.Role != "admin" {
t.Errorf("roles crossed over: A=%s B=%s", gotA.Role, gotB.Role)
}
}
func TestListTenantsExcludesSuspended(t *testing.T) {
live := testsupport.Setup(t)
dead := testsupport.Setup(t)
dead.Suspend(t)
tenants, err := live.Repo.ListTenants(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
seen := map[string]bool{}
for _, tn := range tenants {
seen[tn.Slug] = true
}
if !seen[live.Tenant.Slug] {
t.Error("active tenant missing from the sign-in picker")
}
if seen[dead.Tenant.Slug] {
t.Error("suspended tenant offered in the sign-in picker")
}
}
@@ -0,0 +1,135 @@
package repository
import (
"context"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListAirports returns the airports this tenant actually flies to or from,
// which is what the search form's dropdowns should offer. Airports themselves
// are global, but the filter keeps each agency's UI limited to its network.
func (r *Repository) ListAirports(ctx context.Context, tenantID uuid.UUID) ([]models.Airport, error) {
const q = `
SELECT DISTINCT a.id, a.iata_code, a.name, a.city, a.country, a.timezone
FROM airports a
WHERE EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = $1
AND (f.origin_airport_id = a.id OR f.destination_airport_id = a.id)
)
ORDER BY a.city, a.iata_code`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airports := []models.Airport{}
for rows.Next() {
var a models.Airport
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.City, &a.Country, &a.Timezone); err != nil {
return nil, err
}
airports = append(airports, a)
}
return airports, rows.Err()
}
// ListAirlines returns the carriers present in this tenant's inventory.
func (r *Repository) ListAirlines(ctx context.Context, tenantID uuid.UUID) ([]models.Airline, error) {
const q = `
SELECT DISTINCT al.id, al.iata_code, al.name, COALESCE(al.logo_url, '')
FROM airlines al
JOIN flights f ON f.airline_id = al.id AND f.tenant_id = $1
ORDER BY al.name`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airlines := []models.Airline{}
for rows.Next() {
var a models.Airline
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.LogoURL); err != nil {
return nil, err
}
airlines = append(airlines, a)
}
return airlines, rows.Err()
}
// DashboardStats aggregates one tenant's trading position.
func (r *Repository) DashboardStats(ctx context.Context, tenantID uuid.UUID) (models.DashboardStats, error) {
var s models.DashboardStats
s.Currency = "USD"
const totalsSQL = `
SELECT COUNT(*),
COUNT(*) FILTER (WHERE status = 'confirmed'),
COUNT(*) FILTER (WHERE status = 'cancelled'),
COALESCE(SUM(total_amount) FILTER (WHERE status = 'confirmed'), 0),
COALESCE(SUM(passenger_count) FILTER (WHERE status = 'confirmed'), 0)
FROM bookings
WHERE tenant_id = $1`
if err := r.pool.QueryRow(ctx, totalsSQL, tenantID).Scan(
&s.TotalBookings, &s.ConfirmedBookings, &s.CancelledBookings,
&s.TotalRevenue, &s.SeatsSold,
); err != nil {
return s, err
}
const stockSQL = `
SELECT COUNT(*) FROM flights
WHERE tenant_id = $1 AND departure_time > now() AND status <> 'cancelled'`
if err := r.pool.QueryRow(ctx, stockSQL, tenantID).Scan(&s.FlightsInStock); err != nil {
return s, err
}
const routesSQL = `
SELECT o.iata_code || ' to ' || d.iata_code AS route,
COUNT(*) AS bookings,
COALESCE(SUM(b.total_amount), 0) AS revenue
FROM bookings b
JOIN flights f ON f.id = b.flight_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id
WHERE b.tenant_id = $1 AND b.status = 'confirmed'
GROUP BY route
ORDER BY revenue DESC
LIMIT 5`
rows, err := r.pool.Query(ctx, routesSQL, tenantID)
if err != nil {
return s, err
}
defer rows.Close()
for rows.Next() {
var entry struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
}
if err := rows.Scan(&entry.Route, &entry.Bookings, &entry.Revenue); err != nil {
return s, err
}
s.TopRoutes = append(s.TopRoutes, entry)
}
if err := rows.Err(); err != nil {
return s, err
}
recent, _, err := r.ListBookings(ctx, tenantID, "", 5, 0)
if err != nil {
return s, err
}
s.RecentBookings = recent
return s, nil
}
+39
View File
@@ -0,0 +1,39 @@
// Package repository holds every SQL statement in the service.
//
// Tenant isolation convention: any query touching a tenant-owned table
// (users, flights, bookings, passengers) takes tenantID as its FIRST argument
// and filters on it in the WHERE clause. Callers pass the id from the
// authenticated identity, never from client input.
package repository
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a lookup matches no row within the tenant.
// Handlers map it to 404, which also means a caller probing for another
// tenant's record sees a plain "not found" rather than a permission error.
var ErrNotFound = errors.New("not found")
// ErrNoSeats is returned when a booking asks for more seats than remain.
var ErrNoSeats = errors.New("not enough seats available")
// Repository is the data access layer over a pgx pool.
type Repository struct {
pool *pgxpool.Pool
}
// New builds a Repository around an open pool.
func New(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// isUniqueViolation reports whether err is PostgreSQL's 23505, which lets
// callers retry a generated value instead of failing the request.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+329
View File
@@ -0,0 +1,329 @@
// Package testsupport wires tests to a real PostgreSQL database.
//
// Isolation strategy: every test gets its own freshly created tenant. Because
// the whole application is tenant-scoped by design, a per-test tenant gives
// each test a private world without truncating tables between runs -- and it
// exercises the tenant scoping on every single query rather than trusting it.
//
// Set TEST_DATABASE_URL to point at a throwaway database. The default assumes
// a local "travelhub_test". The schema is applied automatically on first use.
package testsupport
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/config"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Password is the plaintext behind every user created by this package.
const Password = "testpass123"
var (
poolOnce sync.Once
pool *pgxpool.Pool
poolErr error
)
// Fixture is one test's private slice of the world.
type Fixture struct {
Pool *pgxpool.Pool
Repo *repository.Repository
Tenant models.Tenant
Admin models.User
Agent models.User
}
// Setup returns a Fixture backed by a brand-new tenant. The tenant and
// everything that cascades from it are removed when the test finishes.
func Setup(t *testing.T) *Fixture {
t.Helper()
p := sharedPool(t)
ctx := context.Background()
slug := "test-" + uuid.NewString()[:8]
var tenant models.Tenant
err := p.QueryRow(ctx,
`INSERT INTO tenants (slug, name) VALUES ($1, $2)
RETURNING id, slug, name, status, created_at`,
slug, "Test Agency "+slug,
).Scan(&tenant.ID, &tenant.Slug, &tenant.Name, &tenant.Status, &tenant.CreatedAt)
if err != nil {
t.Fatalf("create tenant: %v", err)
}
t.Cleanup(func() {
// ON DELETE CASCADE clears users, flights, bookings and passengers.
if _, err := p.Exec(context.Background(),
`DELETE FROM tenants WHERE id = $1`, tenant.ID); err != nil {
t.Logf("cleanup tenant %s: %v", tenant.Slug, err)
}
})
f := &Fixture{Pool: p, Repo: repository.New(p), Tenant: tenant}
f.Admin = f.NewUser(t, "admin@"+slug+".test", "admin")
f.Agent = f.NewUser(t, "agent@"+slug+".test", "agent")
return f
}
// NewUser adds a user to the fixture's tenant.
func (f *Fixture) NewUser(t *testing.T, email, role string) models.User {
t.Helper()
// MinCost keeps the suite fast; the middleware verifies any cost.
hash, err := bcrypt.GenerateFromPassword([]byte(Password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
var u models.User
err = f.Pool.QueryRow(context.Background(),
`INSERT INTO users (tenant_id, email, password_hash, full_name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, email, password_hash, full_name, role, created_at`,
f.Tenant.ID, email, string(hash), "Test "+role, role,
).Scan(&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt)
if err != nil {
t.Fatalf("create user %s: %v", email, err)
}
return u
}
// FlightOpts describes a flight to insert. Zero values get sensible defaults.
type FlightOpts struct {
Number string
Origin string // IATA, default JFK
Destination string // IATA, default LHR
DepartsIn time.Duration
Duration time.Duration
Cabin string
Price float64
Seats int
Available *int // defaults to Seats
Status string
}
// NewFlight inserts one flight into the fixture's tenant and returns it.
func (f *Fixture) NewFlight(t *testing.T, opts FlightOpts) models.Flight {
t.Helper()
ctx := context.Background()
if opts.Number == "" {
opts.Number = "TT" + uuid.NewString()[:4]
}
if opts.Origin == "" {
opts.Origin = "JFK"
}
if opts.Destination == "" {
opts.Destination = "LHR"
}
if opts.DepartsIn == 0 {
opts.DepartsIn = 48 * time.Hour
}
if opts.Duration == 0 {
opts.Duration = 7 * time.Hour
}
if opts.Cabin == "" {
opts.Cabin = "economy"
}
if opts.Price == 0 {
opts.Price = 500
}
if opts.Seats == 0 {
opts.Seats = 100
}
if opts.Status == "" {
opts.Status = "scheduled"
}
available := opts.Seats
if opts.Available != nil {
available = *opts.Available
}
departs := time.Now().Add(opts.DepartsIn)
var id uuid.UUID
err := f.Pool.QueryRow(ctx,
`INSERT INTO flights (tenant_id, airline_id, flight_number,
origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available, status)
VALUES ($1,
(SELECT id FROM airlines WHERE iata_code = 'AA'),
$2,
(SELECT id FROM airports WHERE iata_code = $3),
(SELECT id FROM airports WHERE iata_code = $4),
$5, $6, 'Boeing 787-9', $7, $8, 'USD', $9, $10, $11)
RETURNING id`,
f.Tenant.ID, opts.Number, opts.Origin, opts.Destination,
departs, departs.Add(opts.Duration), opts.Cabin, opts.Price,
opts.Seats, available, opts.Status,
).Scan(&id)
if err != nil {
t.Fatalf("create flight: %v", err)
}
flight, err := f.Repo.FlightByID(ctx, f.Tenant.ID, id)
if err != nil {
t.Fatalf("read back flight: %v", err)
}
return flight
}
// SeatsAvailable reads the current availability straight from the row, for
// assertions about seat accounting.
func (f *Fixture) SeatsAvailable(t *testing.T, flightID uuid.UUID) int {
t.Helper()
var seats int
err := f.Pool.QueryRow(context.Background(),
`SELECT seats_available FROM flights WHERE id = $1`, flightID).Scan(&seats)
if err != nil {
t.Fatalf("read seats: %v", err)
}
return seats
}
// Suspend flips the tenant to suspended, for tests that check sign-in is
// refused afterwards.
func (f *Fixture) Suspend(t *testing.T) {
t.Helper()
if _, err := f.Pool.Exec(context.Background(),
`UPDATE tenants SET status = 'suspended' WHERE id = $1`, f.Tenant.ID); err != nil {
t.Fatalf("suspend tenant: %v", err)
}
}
// sharedPool opens the pool once per test binary and prepares the schema.
func sharedPool(t *testing.T) *pgxpool.Pool {
t.Helper()
poolOnce.Do(func() {
// Pick up backend/.env so a developer who configured the app can run
// the tests without setting anything extra.
config.LoadDotEnv()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
p, err := pgxpool.New(ctx, databaseURL())
if err != nil {
poolErr = fmt.Errorf("open test pool: %w", err)
return
}
if err := p.Ping(ctx); err != nil {
poolErr = fmt.Errorf("ping test database: %w", err)
return
}
if err := applySchema(ctx, p); err != nil {
poolErr = err
return
}
if err := seedReference(ctx, p); err != nil {
poolErr = err
return
}
pool = p
})
if poolErr != nil {
t.Fatalf("test database unavailable: %v\n"+
"Set TEST_DATABASE_URL, or create the default database with:\n"+
" createdb -U postgres travelhub_test", poolErr)
}
return pool
}
// databaseURL resolves the test DSN, preferring TEST_DATABASE_URL and
// otherwise assembling one from the same DB_* variables the app uses, with the
// database name swapped for the test one.
func databaseURL() string {
if raw := os.Getenv("TEST_DATABASE_URL"); raw != "" {
return raw
}
user := envOr("DB_USER", "postgres")
pass := envOr("DB_PASSWORD", "postgres")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("TEST_DB_NAME", "travelhub_test")
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: host + ":" + port,
Path: "/" + name,
RawQuery: "sslmode=disable",
}
return u.String()
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// applySchema runs 001_init.sql, which is written to be re-runnable.
func applySchema(ctx context.Context, p *pgxpool.Pool) error {
path := filepath.Join(moduleRoot(), "migrations", "001_init.sql")
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read schema %s: %w", path, err)
}
// Exec with no arguments uses the simple protocol, which accepts a script
// of several statements.
if _, err := p.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
return nil
}
// seedReference inserts the global airports and airlines the fixtures need.
func seedReference(ctx context.Context, p *pgxpool.Pool) error {
const sql = `
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai')
ON CONFLICT (iata_code) DO NOTHING;
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways')
ON CONFLICT (iata_code) DO NOTHING;`
if _, err := p.Exec(ctx, sql); err != nil {
return fmt.Errorf("seed reference data: %w", err)
}
return nil
}
// moduleRoot locates backend/ from this file's compile-time path, so tests can
// find migrations no matter which package directory they run from.
func moduleRoot() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return "."
}
// .../backend/internal/testsupport/testsupport.go -> .../backend
return filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
}
+107
View File
@@ -0,0 +1,107 @@
-- TravelHub: multi-tenant flight booking schema
-- Strategy: shared database, shared schema, tenant_id discriminator on every
-- tenant-owned table. Reference data (airports, airlines) is global.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ---------------------------------------------------------------- tenants --
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------------ users --
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'agent'
CHECK (role IN ('admin', 'agent')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The same email may exist in different tenants; unique only within one.
CONSTRAINT users_tenant_email_key UNIQUE (tenant_id, email)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
-- ------------------------------------------------- reference data (global) --
CREATE TABLE IF NOT EXISTS airports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(3) NOT NULL UNIQUE,
name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC'
);
CREATE TABLE IF NOT EXISTS airlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(2) NOT NULL UNIQUE,
name TEXT NOT NULL,
logo_url TEXT
);
-- ---------------------------------------------------------------- flights --
-- Inventory is per-tenant: each agency loads its own negotiated fares.
CREATE TABLE IF NOT EXISTS flights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
airline_id UUID NOT NULL REFERENCES airlines(id),
flight_number TEXT NOT NULL,
origin_airport_id UUID NOT NULL REFERENCES airports(id),
destination_airport_id UUID NOT NULL REFERENCES airports(id),
departure_time TIMESTAMPTZ NOT NULL,
arrival_time TIMESTAMPTZ NOT NULL,
aircraft TEXT NOT NULL DEFAULT '',
cabin_class TEXT NOT NULL DEFAULT 'economy'
CHECK (cabin_class IN ('economy', 'premium_economy', 'business', 'first')),
base_price NUMERIC(10,2) NOT NULL CHECK (base_price >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
seats_total INTEGER NOT NULL CHECK (seats_total > 0),
seats_available INTEGER NOT NULL CHECK (seats_available >= 0),
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'delayed', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT flights_seats_check CHECK (seats_available <= seats_total),
CONSTRAINT flights_times_check CHECK (arrival_time > departure_time)
);
CREATE INDEX IF NOT EXISTS idx_flights_search
ON flights(tenant_id, origin_airport_id, destination_airport_id, departure_time);
-- --------------------------------------------------------------- bookings --
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
flight_id UUID NOT NULL REFERENCES flights(id),
booking_reference TEXT NOT NULL,
passenger_count INTEGER NOT NULL CHECK (passenger_count > 0),
total_amount NUMERIC(10,2) NOT NULL CHECK (total_amount >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'cancelled')),
contact_email TEXT NOT NULL,
contact_phone TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PNR is unique per tenant, not globally.
CONSTRAINT bookings_tenant_ref_key UNIQUE (tenant_id, booking_reference)
);
CREATE INDEX IF NOT EXISTS idx_bookings_tenant_created ON bookings(tenant_id, created_at DESC);
-- ------------------------------------------------------------- passengers --
CREATE TABLE IF NOT EXISTS passengers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth DATE,
passport_number TEXT NOT NULL DEFAULT '',
seat_number TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_passengers_booking ON passengers(booking_id);
+121
View File
@@ -0,0 +1,121 @@
-- Demo data for TravelHub. Safe to re-run: every insert is idempotent.
-- Flight departures are generated relative to now(), so seeded inventory is
-- always in the future no matter when you run this.
-- ---------------------------------------------------------------- tenants --
INSERT INTO tenants (slug, name) VALUES
('skyline', 'Skyline Travel'),
('globetrotter', 'Globetrotter Tours')
ON CONFLICT (slug) DO NOTHING;
-- ------------------------------------------------------------------ users --
-- Passwords are bcrypt hashes produced by pgcrypto's crypt()/gen_salt('bf'),
-- which golang.org/x/crypto/bcrypt verifies directly. Demo password for every
-- seeded account is: demo1234
INSERT INTO users (tenant_id, email, password_hash, full_name, role)
SELECT t.id, v.email, crypt('demo1234', gen_salt('bf')), v.full_name, v.role
FROM tenants t
JOIN (VALUES
('skyline', 'admin@skyline.test', 'Ava Chen', 'admin'),
('skyline', 'agent@skyline.test', 'Marcus Reid', 'agent'),
('globetrotter', 'admin@globetrotter.test', 'Priya Nair', 'admin'),
('globetrotter', 'agent@globetrotter.test', 'Tom Okafor', 'agent')
) AS v(slug, email, full_name, role) ON v.slug = t.slug
ON CONFLICT (tenant_id, email) DO NOTHING;
-- --------------------------------------------------------------- airports --
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('SFO', 'San Francisco International', 'San Francisco', 'United States', 'America/Los_Angeles'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('CDG', 'Charles de Gaulle', 'Paris', 'France', 'Europe/Paris'),
('FRA', 'Frankfurt am Main', 'Frankfurt', 'Germany', 'Europe/Berlin'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai'),
('SIN', 'Changi', 'Singapore', 'Singapore', 'Asia/Singapore'),
('HND', 'Haneda', 'Tokyo', 'Japan', 'Asia/Tokyo'),
('SYD', 'Kingsford Smith', 'Sydney', 'Australia', 'Australia/Sydney'),
('BOM', 'Chhatrapati Shivaji Maharaj', 'Mumbai', 'India', 'Asia/Kolkata'),
('DEL', 'Indira Gandhi International', 'Delhi', 'India', 'Asia/Kolkata')
ON CONFLICT (iata_code) DO NOTHING;
-- --------------------------------------------------------------- airlines --
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways'),
('EK', 'Emirates'),
('SQ', 'Singapore Airlines'),
('LH', 'Lufthansa'),
('AI', 'Air India'),
('UA', 'United Airlines'),
('AF', 'Air France')
ON CONFLICT (iata_code) DO NOTHING;
-- ---------------------------------------------------------------- flights --
-- Each tenant carries its own inventory of the same routes at its own fares:
-- Skyline sells at list price, Globetrotter at a 12% negotiated markup. This
-- is what makes tenant isolation visible in the UI -- same route, different
-- price and different availability depending on who is logged in.
WITH routes(airline, flight_no, origin, dest, dep_hour, dur_hours, cabin, price, seats) AS (
VALUES
('AA', 'AA100', 'JFK', 'LHR', 18, 7, 'economy', 540.00, 180),
('BA', 'BA178', 'JFK', 'LHR', 21, 7, 'business', 2450.00, 40),
('AA', 'AA136', 'LAX', 'JFK', 8, 5, 'economy', 310.00, 200),
('UA', 'UA523', 'SFO', 'JFK', 7, 5, 'economy', 295.00, 190),
('EK', 'EK202', 'JFK', 'DXB', 23, 12, 'economy', 780.00, 350),
('EK', 'EK506', 'DXB', 'BOM', 3, 3, 'economy', 210.00, 300),
('SQ', 'SQ321', 'SIN', 'LHR', 10, 13, 'business', 3100.00, 50),
('SQ', 'SQ001', 'SIN', 'SFO', 9, 15, 'economy', 890.00, 280),
('LH', 'LH400', 'FRA', 'JFK', 13, 8, 'economy', 495.00, 220),
('AF', 'AF006', 'CDG', 'JFK', 11, 8, 'premium_economy', 1150.00, 60),
('AI', 'AI101', 'DEL', 'JFK', 2, 15, 'economy', 720.00, 240),
('AI', 'AI864', 'BOM', 'DEL', 6, 2, 'economy', 85.00, 160),
('BA', 'BA015', 'LHR', 'SYD', 22, 22, 'economy', 1080.00, 260),
('LH', 'LH716', 'FRA', 'HND', 14, 11, 'economy', 640.00, 230)
),
priced AS (
SELECT t.id AS tenant_id,
al.id AS airline_id,
r.flight_no,
o.id AS origin_id,
d.id AS dest_id,
r.cabin,
r.seats,
day.n AS day_offset,
r.dur_hours,
-- Fares drift with departure date so search results look realistic.
ROUND(
(r.price * CASE t.slug WHEN 'globetrotter' THEN 1.12 ELSE 1.00 END)
* (1 + ((day.n % 7) - 3) * 0.035)
, 2) AS price,
date_trunc('day', now()) + (day.n || ' days')::interval
+ (r.dep_hour || ' hours')::interval AS departs
FROM routes r
CROSS JOIN generate_series(1, 21) AS day(n)
CROSS JOIN tenants t
JOIN airlines al ON al.iata_code = r.airline
JOIN airports o ON o.iata_code = r.origin
JOIN airports d ON d.iata_code = r.dest
)
INSERT INTO flights (
tenant_id, airline_id, flight_number, origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available
)
SELECT p.tenant_id, p.airline_id, p.flight_no, p.origin_id, p.dest_id,
p.departs,
p.departs + (p.dur_hours || ' hours')::interval,
'Boeing 787-9',
p.cabin,
p.price,
'USD',
p.seats,
-- Nearer departures are fuller, so the UI shows varied availability.
GREATEST(1, p.seats - ((22 - p.day_offset) * 7) % (p.seats / 2))
FROM priced p
WHERE NOT EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = p.tenant_id
AND f.flight_number = p.flight_no
AND f.departure_time = p.departs
);
+122
View File
@@ -0,0 +1,122 @@
-- Demo reservations, so the dashboard shows a trading picture on first run
-- instead of an empty state. Both agencies have sold tickets, at their own
-- fares, on their own inventory.
--
-- The whole file is one guarded block: if the demo references are already
-- present it returns immediately, which keeps seat counts from being
-- decremented twice on a re-run.
DO $$
DECLARE
already_seeded boolean;
BEGIN
SELECT EXISTS (SELECT 1 FROM bookings WHERE booking_reference = 'QX7A2M')
INTO already_seeded;
IF already_seeded THEN
RAISE NOTICE 'demo bookings already present, skipping';
RETURN;
END IF;
-- Rank each tenant's future inventory so the same flights are chosen on
-- every machine, then attach a booking to a handful of them.
CREATE TEMP TABLE demo_picks ON COMMIT DROP AS
WITH ranked AS (
SELECT f.id AS flight_id,
f.tenant_id,
f.base_price,
f.currency,
t.slug,
row_number() OVER (PARTITION BY t.slug
ORDER BY f.departure_time, f.flight_number) AS rn
FROM flights f
JOIN tenants t ON t.id = f.tenant_id
WHERE f.departure_time > now()
),
spec(slug, rn, pnr, pax, email, phone, status) AS (
VALUES
('skyline', 2, 'QX7A2M', 2, 'harper.quinn@example.com', '+1-212-555-0143', 'confirmed'),
('skyline', 7, 'MT4RB9', 1, 'd.oyelaran@example.com', '+1-415-555-0199', 'confirmed'),
('skyline', 15, 'KP2VJ8', 4, 'the.sandovals@example.com','+1-646-555-0117', 'confirmed'),
('skyline', 23, 'RN9WD3', 2, 'a.lindqvist@example.com', '+44-20-7946-0821','confirmed'),
('skyline', 31, 'HB6ZC5', 3, 'marco.bellini@example.com','+39-06-555-0164', 'confirmed'),
('skyline', 44, 'VF3LK7', 1, 'j.nakamura@example.com', '+81-3-5555-0132', 'cancelled'),
('globetrotter', 4, 'TG8MH2', 2, 'priya.raman@example.com', '+91-22-5555-0178','confirmed'),
('globetrotter', 11, 'WS5QN6', 3, 'okoro.family@example.com', '+234-1-555-0155', 'confirmed'),
('globetrotter', 19, 'ZD7XP4', 1, 'l.fontaine@example.com', '+33-1-5555-0190', 'confirmed'),
('globetrotter', 27, 'CJ2YR8', 2, 'sam.whitaker@example.com', '+61-2-5555-0146', 'confirmed')
),
-- Attribute every demo sale to that tenant's agent account.
selling_agent AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id
FROM users
WHERE role = 'agent'
ORDER BY tenant_id, email
)
SELECT r.flight_id,
r.tenant_id,
a.id AS user_id,
s.pnr,
s.pax,
s.email,
s.phone,
s.status,
ROUND(r.base_price * s.pax, 2) AS total_amount,
r.currency
FROM spec s
JOIN ranked r ON r.slug = s.slug AND r.rn = s.rn
JOIN selling_agent a ON a.tenant_id = r.tenant_id;
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency, status,
contact_email, contact_phone, created_at)
SELECT p.tenant_id, p.user_id, p.flight_id, p.pnr, p.pax, p.total_amount,
p.currency, p.status, p.email, p.phone,
-- Spread creation times over the past three weeks.
now() - ((row_number() OVER (ORDER BY p.pnr) * 47) || ' hours')::interval
FROM demo_picks p;
-- Give each booking its own travellers from a small pool of names.
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
SELECT b.tenant_id,
b.id,
n.first_name,
n.last_name,
DATE '1970-01-01' + ((n.idx * 1447) || ' days')::interval,
n.passport
FROM (
SELECT bk.id, bk.tenant_id, bk.passenger_count,
row_number() OVER (ORDER BY bk.booking_reference) AS bn
FROM bookings bk
JOIN demo_picks dp ON dp.pnr = bk.booking_reference
AND dp.tenant_id = bk.tenant_id
) b
CROSS JOIN LATERAL generate_series(1, b.passenger_count) AS g(i)
JOIN (VALUES
(0, 'Harper', 'Quinn', 'P1029384'),
(1, 'Milo', 'Vance', 'P5568211'),
(2, 'Ada', 'Okonjo', 'P7741903'),
(3, 'Ben', 'Sorensen', 'P3310927'),
(4, 'Ines', 'Marchetti', 'P9084417'),
(5, 'Theo', 'Nakamura', 'P2276650'),
(6, 'Rania', 'Haddad', 'P6693108'),
(7, 'Oscar', 'Lindqvist', 'P4417789'),
(8, 'Nadia', 'Petrov', 'P8820134'),
(9, 'Caleb', 'Mwangi', 'P3345562'),
(10, 'Sofia', 'Delgado', 'P7719026'),
(11, 'Arjun', 'Raman', 'P5502943')
) AS n(idx, first_name, last_name, passport)
ON n.idx = (b.bn * 5 + g.i) % 12;
-- Confirmed sales consume seats; cancelled ones do not.
UPDATE flights f
SET seats_available = GREATEST(0, f.seats_available - sold.pax)
FROM (SELECT flight_id, SUM(pax) AS pax
FROM demo_picks
WHERE status = 'confirmed'
GROUP BY flight_id) sold
WHERE f.id = sold.flight_id;
RAISE NOTICE 'demo bookings created';
END $$;
+48
View File
@@ -0,0 +1,48 @@
/** The seeded demo accounts, from backend/migrations/002_seed.sql. */
export interface Account {
tenant: string;
tenantName: string;
email: string;
password: string;
fullName: string;
role: 'admin' | 'agent';
}
export const DEMO_PASSWORD = 'demo1234';
export const SKYLINE_AGENT: Account = {
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'agent@skyline.test',
password: DEMO_PASSWORD,
fullName: 'Marcus Reid',
role: 'agent',
};
export const SKYLINE_ADMIN: Account = {
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'admin@skyline.test',
password: DEMO_PASSWORD,
fullName: 'Ava Chen',
role: 'admin',
};
export const GLOBETROTTER_AGENT: Account = {
tenant: 'globetrotter',
tenantName: 'Globetrotter Tours',
email: 'agent@globetrotter.test',
password: DEMO_PASSWORD,
fullName: 'Tom Okafor',
role: 'agent',
};
export const GLOBETROTTER_ADMIN: Account = {
tenant: 'globetrotter',
tenantName: 'Globetrotter Tours',
email: 'admin@globetrotter.test',
password: DEMO_PASSWORD,
fullName: 'Priya Nair',
role: 'admin',
};
+265
View File
@@ -0,0 +1,265 @@
/**
* Simulated backend for UI tests.
*
* The end-to-end specs drive the real stack, which is the right way to prove
* the system works. But some conditions are impractical to stage for real:
* a 500 from the database, a fifteen-second response, a seat sold by someone
* else in the half second between opening checkout and confirming, an agency
* with a hundred thousand flights, a passenger whose name is 200 characters
* long.
*
* These helpers intercept `/api/**` in the browser and answer it directly, so
* a test can put the UI in any of those states deterministically, in
* milliseconds, without touching the database.
*/
import { Page } from '@playwright/test';
// --- canned data -----------------------------------------------------------
export const TENANTS = [
{ slug: 'skyline', name: 'Skyline Travel' },
{ slug: 'globetrotter', name: 'Globetrotter Tours' },
];
export const SESSION = {
user: {
id: 'u-1',
tenantId: 't-1',
email: 'agent@skyline.test',
fullName: 'Marcus Reid',
role: 'agent',
createdAt: '2026-01-01T00:00:00Z',
},
tenant: {
id: 't-1',
slug: 'skyline',
name: 'Skyline Travel',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
},
};
export const AIRPORTS = [
{ id: 'a-jfk', iataCode: 'JFK', name: 'John F. Kennedy International', city: 'New York', country: 'United States', timezone: 'America/New_York' },
{ id: 'a-lhr', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'United Kingdom', timezone: 'Europe/London' },
];
export const AIRLINES = [{ id: 'al-aa', iataCode: 'AA', name: 'American Airlines' }];
export function makeFlight(overrides: Record<string, unknown> = {}) {
return {
id: 'f-1',
flightNumber: 'AA100',
airline: AIRLINES[0],
origin: AIRPORTS[0],
destination: AIRPORTS[1],
departureTime: '2026-12-19T18:00:00Z',
arrivalTime: '2026-12-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 483.3,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 165,
status: 'scheduled',
...overrides,
};
}
export function makeBooking(overrides: Record<string, unknown> = {}) {
return {
id: 'b-1',
bookingReference: 'SIM001',
flight: makeFlight(),
passengers: [{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield' }],
passengerCount: 1,
totalAmount: 483.3,
currency: 'USD',
status: 'confirmed',
contactEmail: 'ops@example.com',
contactPhone: '',
bookedBy: 'Marcus Reid',
createdAt: '2026-09-12T13:10:00Z',
...overrides,
};
}
export function makeDashboard(overrides: Record<string, unknown> = {}) {
return {
totalBookings: 6,
confirmedBookings: 5,
cancelledBookings: 1,
totalRevenue: 7263.16,
currency: 'USD',
flightsInStock: 294,
seatsSold: 12,
recentBookings: [makeBooking()],
topRoutes: [{ route: 'JFK to LHR', bookings: 3, revenue: 4000 }],
...overrides,
};
}
export function page$<T>(items: T[], total = items.length, limit = 10, offset = 0) {
return { items, total, limit, offset };
}
// --- scenario --------------------------------------------------------------
/**
* How one endpoint should answer. A bare value is a 200 with that value as the
* body; use `reply`, `fail` or `slow` for anything else.
*
* The envelope carries a symbol brand rather than being 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` would
* silently read a booking as an HTTP response.
*/
const ENVELOPE = Symbol('travelhub.reply');
export interface ReplyEnvelope {
[ENVELOPE]: true;
status: number;
body: unknown;
delayMs?: number;
}
export type Reply = ReplyEnvelope | unknown;
/** An explicit response: status, body and optional latency. */
export function reply(opts: { status?: number; body?: unknown; delayMs?: number }): ReplyEnvelope {
return {
[ENVELOPE]: true,
status: opts.status ?? 200,
body: opts.body ?? {},
delayMs: opts.delayMs,
};
}
/** An error response, in the shape the Go API uses. */
export function fail(status: number, error = 'simulated failure'): ReplyEnvelope {
return reply({ status, body: { error } });
}
/** A successful but slow response, for asserting on loading states. */
export function slow(body: unknown, delayMs: number): ReplyEnvelope {
return reply({ status: 200, body, delayMs });
}
export interface Scenario {
/** Latency applied to every endpoint that does not set its own. */
delayMs?: number;
/** Abort every API call, as though the network were gone. */
offline?: boolean;
session?: Reply;
tenants?: Reply;
airports?: Reply;
airlines?: Reply;
flights?: Reply;
flightById?: Reply;
bookings?: Reply;
bookingById?: Reply;
createBooking?: Reply;
cancelBooking?: Reply;
dashboard?: Reply;
}
function normalise(value: Reply): { status: number; body: unknown; delayMs?: number } {
if (value !== null && typeof value === 'object' && ENVELOPE in value) {
const envelope = value as ReplyEnvelope;
return { status: envelope.status, body: envelope.body, delayMs: envelope.delayMs };
}
return { status: 200, body: value };
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Installs the simulated API. Anything the scenario does not mention falls
* back to a sensible, healthy default, so a test only spells out the one
* condition it is about.
*/
export async function simulateApi(page: Page, scenario: Scenario = {}): Promise<void> {
const defaults: Scenario = {
session: SESSION,
tenants: TENANTS,
airports: AIRPORTS,
airlines: AIRLINES,
flights: page$([makeFlight()]),
flightById: makeFlight(),
bookings: page$([makeBooking()]),
bookingById: makeBooking(),
createBooking: reply({ status: 201, body: makeBooking() }),
cancelBooking: makeBooking({ status: 'cancelled' }),
dashboard: makeDashboard(),
};
const merged = { ...defaults, ...scenario };
await page.route('**/api/**', async (route) => {
if (scenario.offline) {
await route.abort('internetdisconnected');
return;
}
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
let answer: Reply;
if (path === '/api/health') {
answer = { status: 'ok' };
} else if (path === '/api/tenants') {
answer = merged.tenants;
} else if (path === '/api/session') {
answer = merged.session;
} else if (path === '/api/airports') {
answer = merged.airports;
} else if (path === '/api/airlines') {
answer = merged.airlines;
} else if (path === '/api/dashboard') {
answer = merged.dashboard;
} else if (path === '/api/flights') {
answer = merged.flights;
} else if (path.startsWith('/api/flights/')) {
answer = merged.flightById;
} else if (path.endsWith('/cancel')) {
answer = merged.cancelBooking;
} else if (path === '/api/bookings') {
answer = method === 'POST' ? merged.createBooking : merged.bookings;
} else if (path.startsWith('/api/bookings/')) {
answer = merged.bookingById;
} else {
answer = fail(404, 'not found');
}
const { status, body, delayMs } = normalise(answer);
const wait = delayMs ?? merged.delayMs ?? 0;
if (wait > 0) {
await sleep(wait);
}
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/**
* Puts credentials in localStorage before the app boots, so a simulated test
* lands straight on the screen under test instead of signing in first.
*/
export async function primeCredentials(page: Page, tenant = 'skyline'): Promise<void> {
await page.addInitScript(
([slug]) => {
localStorage.setItem(
'travelhub.credentials',
JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }),
);
},
[tenant],
);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Evidence capture for passing tests.
*
* Playwright keeps screenshots and traces only when something fails, which is
* the right default for debugging but useless as a record of what the suite
* actually proved. This fixture lets a test mark the moments that matter and
* record the data it observed, so a successful run produces a readable report
* rather than a row of green ticks.
*
* await evidence.step('Search results for JFK to LHR', { total: 42, fare: 483.3 });
*
* Each call attaches a screenshot plus a JSON blob. `reporters/evidence.ts`
* turns those attachments into the report.
*/
import { Page, TestInfo } from '@playwright/test';
/** Attachment name prefixes the reporter looks for. */
export const SHOT_PREFIX = 'evidence';
export const DATA_PREFIX = 'evidence-data';
export type Facts = Record<string, unknown>;
export class Evidence {
private sequence = 0;
constructor(
private readonly page: Page,
private readonly info: TestInfo,
) {}
/**
* Records a named moment: a screenshot of the current page, and optionally
* the data the test observed there.
*/
async step(name: string, facts?: Facts): Promise<void> {
const seq = this.next();
const shot = await this.page.screenshot({ fullPage: false, animations: 'disabled' });
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
body: shot,
contentType: 'image/png',
});
if (facts) {
await this.attachFacts(seq, name, facts);
}
}
/** Records data with no screenshot -- for values read across several pages. */
async note(name: string, facts: Facts): Promise<void> {
await this.attachFacts(this.next(), name, facts);
}
/**
* Records the whole scrollable page rather than the viewport. Slower, so it
* is reserved for the screens worth showing in full.
*/
async fullPage(name: string, facts?: Facts): Promise<void> {
const seq = this.next();
const shot = await this.page.screenshot({ fullPage: true, animations: 'disabled' });
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
body: shot,
contentType: 'image/png',
});
if (facts) {
await this.attachFacts(seq, name, facts);
}
}
private async attachFacts(seq: string, name: string, facts: Facts): Promise<void> {
await this.info.attach(`${DATA_PREFIX}:${seq}:${name}`, {
body: Buffer.from(JSON.stringify(facts, null, 2), 'utf8'),
contentType: 'application/json',
});
}
private next(): string {
this.sequence += 1;
return String(this.sequence).padStart(2, '0');
}
}
+88
View File
@@ -0,0 +1,88 @@
import { expect, test as base } from '@playwright/test';
import { Account } from './accounts';
import { Evidence } from './evidence';
import { BookingDetailPage, BookingsPage } from '../pages/bookings.page';
import { CheckoutPage } from '../pages/checkout.page';
import { DashboardPage } from '../pages/dashboard.page';
import { LoginPage } from '../pages/login.page';
import { SearchPage } from '../pages/search.page';
import { ShellPage } from '../pages/shell.page';
interface Fixtures {
login: LoginPage;
shell: ShellPage;
search: SearchPage;
checkout: CheckoutPage;
bookings: BookingsPage;
bookingDetail: BookingDetailPage;
dashboard: DashboardPage;
/** Signs in and lands on the dashboard. */
signInAs: (account: Account) => Promise<void>;
/** Records screenshots and observed data for the run report. */
evidence: Evidence;
/** Console and uncaught errors seen during the test; asserted empty at the end. */
consoleErrors: string[];
}
export const test = base.extend<Fixtures>({
login: async ({ page }, use) => use(new LoginPage(page)),
shell: async ({ page }, use) => use(new ShellPage(page)),
search: async ({ page }, use) => use(new SearchPage(page)),
checkout: async ({ page }, use) => use(new CheckoutPage(page)),
bookings: async ({ page }, use) => use(new BookingsPage(page)),
bookingDetail: async ({ page }, use) => use(new BookingDetailPage(page)),
dashboard: async ({ page }, use) => use(new DashboardPage(page)),
signInAs: async ({ login }, use) => {
await use(async (account: Account) => {
await login.signIn(account);
});
},
evidence: async ({ page }, use, testInfo) => {
await use(new Evidence(page, testInfo));
},
// Every test also asserts the browser console stayed quiet. A stack trace in
// the console is a real defect even when the assertions happen to pass.
consoleErrors: [
async ({ page }, use) => {
const errors: string[] = [];
page.on('console', (message) => {
if (message.type() !== 'error') {
return;
}
// The browser logs this for any non-2xx response, including the 401s
// and 404s these tests deliberately provoke. It says nothing about
// whether the application handled them, so it is not a defect signal.
if (message.text().startsWith('Failed to load resource:')) {
return;
}
errors.push(`console.error: ${message.text()}`);
});
page.on('pageerror', (error) => {
errors.push(`uncaught: ${error.message}`);
});
await use(errors);
expect(errors, 'the browser console should be clean').toEqual([]);
},
{ auto: true },
],
});
export { expect } from '@playwright/test';
/**
* A surname unique to this test run, so a booking can be found again in a list
* that other tests and the seed data also write to.
*/
export function uniqueSurname(prefix = 'E2E'): string {
return `${prefix}${Date.now().toString(36).toUpperCase()}`;
}
+93
View File
@@ -0,0 +1,93 @@
{
"name": "travelhub-e2e",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "travelhub-e2e",
"version": "0.0.0",
"devDependencies": {
"@playwright/test": "^1.50.0",
"@types/node": "^22.10.0",
"typescript": "~5.7.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "22.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "travelhub-e2e",
"version": "0.0.0",
"private": true,
"description": "Coded UI tests for TravelHub, driven through a real browser.",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
"report": "playwright show-report",
"install-browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@types/node": "^22.10.0",
"typescript": "~5.7.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
import { expect, Locator, Page } from '@playwright/test';
/** The bookings list. */
export class BookingsPage {
readonly rows: Locator;
readonly emptyState: Locator;
readonly filterAll: Locator;
readonly filterConfirmed: Locator;
readonly filterCancelled: Locator;
constructor(private readonly page: Page) {
this.rows = page.locator('table.data tbody tr');
this.emptyState = page.locator('.empty');
this.filterAll = page.locator('.filter', { hasText: 'All' });
this.filterConfirmed = page.locator('.filter', { hasText: 'Confirmed' });
this.filterCancelled = page.locator('.filter', { hasText: 'Cancelled' });
}
async goto(): Promise<void> {
await this.page.goto('/bookings');
await this.settled();
}
/** Waits for either a populated table or the empty state. */
async settled(): Promise<void> {
await expect(this.page.locator('.filters')).toBeVisible();
await expect(this.page.locator('.skeleton')).toHaveCount(0);
}
rowFor(reference: string): Locator {
return this.rows.filter({ hasText: reference });
}
async open(reference: string): Promise<void> {
await this.rowFor(reference).getByRole('link', { name: 'View' }).click();
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
}
async filterBy(status: 'all' | 'confirmed' | 'cancelled'): Promise<void> {
const button = {
all: this.filterAll,
confirmed: this.filterConfirmed,
cancelled: this.filterCancelled,
}[status];
await button.click();
await this.settled();
}
}
/** A single booking, with its cancel flow. */
export class BookingDetailPage {
readonly confirmationBanner: Locator;
readonly reference: Locator;
readonly statusBadge: Locator;
readonly travellerRows: Locator;
readonly totalPaid: Locator;
readonly cancelButton: Locator;
readonly confirmCancelButton: Locator;
readonly keepButton: Locator;
readonly cancelledNotice: Locator;
constructor(page: Page) {
this.confirmationBanner = page.locator('.confirmed-banner');
this.reference = page.locator('.sum-list div', { hasText: 'Reference' }).locator('dd');
this.statusBadge = page.locator('.head-actions .badge');
this.travellerRows = page.locator('table.data tbody tr');
this.totalPaid = page.locator('.total-row strong');
this.cancelButton = page.getByRole('button', { name: 'Cancel booking' });
this.confirmCancelButton = page.getByRole('button', { name: /Yes, cancel booking|Cancelling/ });
this.keepButton = page.getByRole('button', { name: 'Keep it' });
this.cancelledNotice = page.locator('.alert-info');
}
async bookingReference(): Promise<string> {
return (await this.reference.innerText()).trim();
}
/**
* Clicks through the two-step cancel confirmation.
*
* The caller must name the booking it means to void. Cancelling is
* destructive and these tests share a database with seeded demo data, so the
* page object refuses to act on whatever booking happens to be on screen
* after a mis-step earlier in the test.
*/
async cancel(expectedReference: string): Promise<void> {
await expect(
this.reference,
`refusing to cancel: expected booking ${expectedReference} on screen`,
).toHaveText(expectedReference);
await this.cancelButton.click();
await this.confirmCancelButton.click();
await expect(this.cancelledNotice).toBeVisible();
}
}
+76
View File
@@ -0,0 +1,76 @@
import { expect, Locator, Page } from '@playwright/test';
export interface Traveller {
firstName: string;
lastName: string;
dateOfBirth?: string;
passportNumber?: string;
}
/** The booking form. */
export class CheckoutPage {
readonly travellerFieldsets: Locator;
readonly addTraveller: Locator;
readonly contactEmail: Locator;
readonly contactPhone: Locator;
readonly confirm: Locator;
readonly total: Locator;
readonly error: Locator;
readonly seatsLeft: Locator;
constructor(private readonly page: Page) {
this.travellerFieldsets = page.locator('.pax');
this.addTraveller = page.getByRole('button', { name: '+ Add traveller' });
this.contactEmail = page.locator('#contactEmail');
this.contactPhone = page.locator('#contactPhone');
this.confirm = page.getByRole('button', { name: /Confirm booking|Confirming/ });
this.total = page.locator('.fare-row.total strong');
this.error = page.locator('.alert-error');
this.seatsLeft = page.locator('.sum-list div', { hasText: 'Seats left' }).locator('dd');
}
async waitUntilReady(): Promise<void> {
await expect(this.travellerFieldsets.first()).toBeVisible();
}
async totalAmount(): Promise<number> {
const text = await this.total.innerText();
return Number(text.replace(/[^0-9.]/g, ''));
}
/** Fills one traveller block, adding a row first if necessary. */
async fillTraveller(index: number, traveller: Traveller): Promise<void> {
// Wait for each added row to render before deciding whether another is
// needed -- re-counting immediately races Angular and adds one too many.
let count = await this.travellerFieldsets.count();
while (count <= index) {
await this.addTraveller.click();
count += 1;
await expect(this.travellerFieldsets).toHaveCount(count);
}
await this.page.locator(`#first-${index}`).fill(traveller.firstName);
await this.page.locator(`#last-${index}`).fill(traveller.lastName);
if (traveller.dateOfBirth) {
await this.page.locator(`#dob-${index}`).fill(traveller.dateOfBirth);
}
if (traveller.passportNumber) {
await this.page.locator(`#passport-${index}`).fill(traveller.passportNumber);
}
}
async fillTravellers(travellers: Traveller[]): Promise<void> {
for (let i = 0; i < travellers.length; i++) {
await this.fillTraveller(i, travellers[i]);
}
}
async removeTraveller(index: number): Promise<void> {
await this.travellerFieldsets.nth(index).getByRole('button', { name: 'Remove' }).click();
}
/** Confirms and waits for the confirmation screen. */
async confirmBooking(): Promise<void> {
await this.confirm.click();
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, Locator, Page } from '@playwright/test';
/** The agency overview. */
export class DashboardPage {
readonly greeting: Locator;
readonly tiles: Locator;
readonly recentRows: Locator;
readonly topRoutes: Locator;
constructor(private readonly page: Page) {
this.greeting = page.locator('h1');
this.tiles = page.locator('.tile');
this.recentRows = page.locator('table.data tbody tr');
this.topRoutes = page.locator('.route-row');
}
async goto(): Promise<void> {
await this.page.goto('/dashboard');
await this.settled();
}
async settled(): Promise<void> {
await expect(this.page.locator('.skeleton')).toHaveCount(0);
await expect(this.tiles.first()).toBeVisible();
}
/** Reads one headline figure by its label, e.g. "Revenue" or "Seats sold". */
async tileValue(label: string): Promise<number> {
const tile = this.tiles.filter({ has: this.page.locator('.tile-label', { hasText: label }) });
const text = await tile.locator('.tile-value').innerText();
return Number(text.replace(/[^0-9.]/g, ''));
}
}
+56
View File
@@ -0,0 +1,56 @@
import { expect, Locator, Page } from '@playwright/test';
import { Account } from '../fixtures/accounts';
/** The sign-in screen: agency picker, credentials, demo shortcuts. */
export class LoginPage {
readonly tenantSelect: Locator;
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
readonly demoChips: Locator;
constructor(private readonly page: Page) {
this.tenantSelect = page.locator('#tenant');
this.email = page.locator('#email');
this.password = page.locator('#password');
// Matched by role rather than label: the button relabels itself to
// "Signing in…" while a request is in flight, and tests assert on that.
this.submit = page.locator('form button[type="submit"]');
this.error = page.locator('.alert-error');
this.demoChips = page.locator('.demo-chip');
}
async goto(): Promise<void> {
await this.page.goto('/login');
await expect(this.submit).toBeVisible();
}
/** Fills the form from the demo shortcut for the given account. */
async useDemoChip(account: Account): Promise<void> {
await this.demoChips.filter({ hasText: account.email }).first().click();
}
async fill(account: Account, password = account.password): Promise<void> {
await this.tenantSelect.selectOption(account.tenant);
await this.email.fill(account.email);
await this.password.fill(password);
}
/** Signs in and waits for the console to appear. */
async signIn(account: Account): Promise<void> {
await this.goto();
await this.useDemoChip(account);
await this.submit.click();
await this.page.waitForURL('**/dashboard');
}
/** Attempts a sign-in that is expected to fail, and returns the message. */
async signInExpectingFailure(account: Account, password: string): Promise<string> {
await this.fill(account, password);
await this.submit.click();
await expect(this.error).toBeVisible();
return (await this.error.innerText()).trim();
}
}
+119
View File
@@ -0,0 +1,119 @@
import { expect, Locator, Page } from '@playwright/test';
/** One flight result card. */
export class FlightCard {
constructor(readonly root: Locator) {}
get price(): Locator {
return this.root.locator('.price');
}
get seatsBadge(): Locator {
return this.root.locator('.fare-tags .badge').last();
}
get selectButton(): Locator {
return this.root.getByRole('button', { name: 'Select' });
}
async fare(): Promise<number> {
const text = (await this.price.innerText()).replace(/[^0-9.]/g, '');
return Number(text);
}
async seatsLeft(): Promise<number> {
const text = await this.seatsBadge.innerText();
return Number(text.replace(/[^0-9]/g, ''));
}
async flightNumber(): Promise<string> {
return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim();
}
async durationMinutes(): Promise<number> {
const text = await this.root.locator('.leg-duration').innerText();
const match = /(?:(\d+)h\s*)?(\d+)m/.exec(text);
if (!match) {
throw new Error(`unparseable duration: ${text}`);
}
return Number(match[1] ?? 0) * 60 + Number(match[2]);
}
}
/** The flight search screen. */
export class SearchPage {
readonly origin: Locator;
readonly destination: Locator;
readonly date: Locator;
readonly cabin: Locator;
readonly travellers: Locator;
readonly searchButton: Locator;
readonly resetButton: Locator;
readonly sort: Locator;
readonly resultSummary: Locator;
readonly results: Locator;
readonly emptyState: Locator;
readonly nextPage: Locator;
readonly prevPage: Locator;
constructor(private readonly page: Page) {
this.origin = page.locator('#origin');
this.destination = page.locator('#destination');
this.date = page.locator('#date');
this.cabin = page.locator('#cabin');
this.travellers = page.locator('#passengers');
this.searchButton = page.getByRole('button', { name: 'Search', exact: true });
this.resetButton = page.getByRole('button', { name: 'Reset' });
this.sort = page.locator('.sort select');
this.resultSummary = page.locator('.results-bar span').first();
this.results = page.locator('.flight');
this.emptyState = page.locator('.empty');
this.nextPage = page.getByRole('button', { name: 'Next →' });
this.prevPage = page.getByRole('button', { name: '← Previous' });
}
async goto(): Promise<void> {
await this.page.goto('/search');
await this.settled();
}
card(index = 0): FlightCard {
return new FlightCard(this.results.nth(index));
}
/** Waits for the result list to stop showing skeletons. */
async settled(): Promise<void> {
await expect(this.page.locator('.skeleton-card')).toHaveCount(0);
await expect(this.resultSummary).not.toHaveText(/Searching/);
}
async searchRoute(from: string, to: string): Promise<void> {
await this.origin.selectOption(from);
await this.destination.selectOption(to);
await this.searchButton.click();
await this.settled();
}
async setTravellers(n: number): Promise<void> {
await this.travellers.selectOption(String(n));
await this.searchButton.click();
await this.settled();
}
async sortBy(value: 'price' | 'duration' | 'departure'): Promise<void> {
await this.sort.selectOption(value);
await this.settled();
}
/** Reads the "Showing 1-10 of 42 flights" line. */
async totalResults(): Promise<number> {
const text = await this.resultSummary.innerText();
const match = /of (\d+) flights/.exec(text);
return match ? Number(match[1]) : 0;
}
/** Finds the card for a given flight number on the current page. */
cardForFlight(flightNumber: string): FlightCard {
return new FlightCard(this.results.filter({ hasText: flightNumber }).first());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Locator, Page } from '@playwright/test';
/** The signed-in chrome: sidebar navigation and the account bar. */
export class ShellPage {
readonly agencyName: Locator;
readonly agencySlug: Locator;
readonly accountName: Locator;
readonly accountMeta: Locator;
readonly signOut: Locator;
constructor(private readonly page: Page) {
this.agencyName = page.locator('.tenant-name');
this.agencySlug = page.locator('.tenant-slug');
this.accountName = page.locator('.account-name');
this.accountMeta = page.locator('.account-meta');
this.signOut = page.getByRole('button', { name: 'Sign out' });
}
/**
* Navigates by sidebar link. The href selector is deliberate: several screens
* also carry a "Search flights" call-to-action button, and matching by role
* and name alone would be ambiguous.
*/
async goToDashboard(): Promise<void> {
await this.page.locator('.nav-link[href="/dashboard"]').click();
await this.page.waitForURL('**/dashboard');
}
async goToSearch(): Promise<void> {
await this.page.locator('.nav-link[href="/search"]').click();
await this.page.waitForURL('**/search');
}
async goToBookings(): Promise<void> {
await this.page.locator('.nav-link[href="/bookings"]').click();
await this.page.waitForURL('**/bookings');
}
async signOutAndWait(): Promise<void> {
await this.signOut.click();
await this.page.waitForURL('**/login');
}
}
+76
View File
@@ -0,0 +1,76 @@
import { defineConfig, devices } from '@playwright/test';
const WEB = process.env.E2E_BASE_URL ?? 'http://localhost:4200';
const API = process.env.E2E_API_URL ?? 'http://localhost:8080';
export default defineConfig({
testDir: './tests',
outputDir: './test-results',
// These tests share one database, and several of them assert on seat counts
// and booking totals. Running a single worker keeps those assertions
// meaningful; the suite is small enough that the wall-clock cost is minor.
workers: 1,
fullyParallel: false,
// A stray test.only should fail the build rather than silently skip the suite.
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
timeout: 60_000,
expect: { timeout: 10_000 },
// The evidence reporter turns the screenshots and data captured by passing
// tests into a readable run report; the html one stays for debugging failures.
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']]
: [['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']],
use: {
baseURL: WEB,
trace: 'retain-on-failure',
// 'on' rather than 'only-on-failure': the evidence report is a record of
// what passed, so every test contributes at least a final screenshot.
screenshot: 'on',
video: 'retain-on-failure',
actionTimeout: 15_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
},
// A narrow viewport, because the console is built to work on a phone too.
// Only the read-mostly specs run here: the booking flows write to a shared
// database, and running them twice would churn the demo data for no gain.
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
testMatch: /(auth|responsive)\.spec\.ts/,
},
],
// Starts the Go API and the Angular dev server if they are not already up, so
// `npm test` is the only command a developer or CI job needs.
webServer: [
{
command: 'go run ./cmd/api',
cwd: '../backend',
url: `${API}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'ignore',
stderr: 'pipe',
},
{
command: 'npm start',
cwd: '../frontend',
url: WEB,
reuseExistingServer: !process.env.CI,
timeout: 240_000,
stdout: 'ignore',
stderr: 'pipe',
},
],
});
+281
View File
@@ -0,0 +1,281 @@
/**
* Builds a run report from the evidence captured by passing tests.
*
* Playwright's own HTML report is a debugging tool: it shows what went wrong.
* This one is the opposite -- a record of what the suite proved, with the
* screenshots and the data each scenario observed, readable by somebody who
* will never open a terminal.
*
* Output lands in `e2e/evidence-report/`:
* index.html the report
* report.json the same content, machine readable
* report.md a text summary for pasting into a ticket or a PR
* shots/ the screenshots
*/
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import { DATA_PREFIX, SHOT_PREFIX } from '../fixtures/evidence';
import { renderHtml } from './render-html';
import {
RunMeta,
Scenario,
Step,
Summary,
areaFor,
formatValue,
friendly,
group,
label,
sentenceCase,
} from './report-model';
const OUT_DIR = resolve(__dirname, '..', 'evidence-report');
const SHOT_DIR = join(OUT_DIR, 'shots');
export default class EvidenceReporter implements Reporter {
private readonly scenarios: Scenario[] = [];
private startedAt = Date.now();
private rootDir = '';
private meta: RunMeta = { baseURL: 'unknown', projects: [], workers: 1 };
onBegin(config: FullConfig, _suite: Suite): void {
this.startedAt = Date.now();
this.rootDir = config.rootDir;
this.meta = {
baseURL: String(config.projects[0]?.use?.baseURL ?? 'unknown'),
projects: config.projects.map((project) => {
const viewport = project.use?.viewport;
const size = viewport ? `${viewport.width} x ${viewport.height}` : 'default size';
return `${project.name} (${size})`;
}),
workers: config.workers,
};
rmSync(OUT_DIR, { recursive: true, force: true });
mkdirSync(SHOT_DIR, { recursive: true });
}
onTestEnd(test: TestCase, result: TestResult): void {
const path = test.titlePath().filter(Boolean);
// titlePath is [project, file, ...describes, title]
const project = path[0] ?? 'default';
const file = relative(this.rootDir, test.location.file).split(sep).join('/');
const title = path[path.length - 1] ?? test.title;
const suite = path.slice(2, -1).join(' › ');
const steps = this.collectSteps(test, result);
this.scenarios.push({
project,
file,
suite,
title,
status: result.status,
expected: result.status === test.expectedStatus,
durationMs: result.duration,
steps,
error: result.error?.message?.split('\n').slice(0, 4).join('\n'),
});
}
/** Pairs `evidence:NN:name` screenshots with `evidence-data:NN:name` blobs. */
private collectSteps(test: TestCase, result: TestResult): Step[] {
const bySeq = new Map<string, Step>();
for (const attachment of result.attachments) {
const match = /^(evidence|evidence-data):(\d+):(.*)$/.exec(attachment.name);
if (!match) {
continue;
}
const [, kind, seq, name] = match;
let step = bySeq.get(seq);
if (!step) {
step = { seq, name };
bySeq.set(seq, step);
}
if (kind === SHOT_PREFIX) {
// An attachment made with `body` carries the bytes in memory; one made
// with `path` points at a file on disk. Both shapes occur.
const safe = `${slug(test.id)}-${seq}.png`;
try {
if (attachment.body) {
writeFileSync(join(SHOT_DIR, safe), attachment.body);
step.shot = `shots/${safe}`;
} else if (attachment.path) {
copyFileSync(attachment.path, join(SHOT_DIR, safe));
step.shot = `shots/${safe}`;
}
} catch {
// A screenshot that could not be saved should not sink the report.
}
}
if (kind === DATA_PREFIX) {
const raw = attachment.body
? attachment.body.toString('utf8')
: attachment.path
? safeRead(attachment.path)
: '';
if (raw) {
try {
step.facts = JSON.parse(raw) as Record<string, unknown>;
} catch {
step.facts = { raw };
}
}
}
}
const steps = [...bySeq.values()].sort((a, b) => a.seq.localeCompare(b.seq));
if (steps.length > 0) {
return steps;
}
// Not every test is instrumented. Fall back to Playwright's own
// end-of-test screenshot so the report still shows what the run looked
// like, just without the narrated data.
const auto = result.attachments.find(
(a) => a.name === 'screenshot' && a.contentType === 'image/png',
);
if (!auto) {
return [];
}
const safe = `${slug(test.id)}-final.png`;
try {
if (auto.body) {
writeFileSync(join(SHOT_DIR, safe), auto.body);
} else if (auto.path) {
copyFileSync(auto.path, join(SHOT_DIR, safe));
} else {
return [];
}
} catch {
return [];
}
return [{ seq: '01', name: 'Final state', shot: `shots/${safe}` }];
}
async onEnd(result: FullResult): Promise<void> {
const durationMs = Date.now() - this.startedAt;
const summary = {
generatedAt: new Date().toISOString(),
outcome: result.status,
durationMs,
total: this.scenarios.length,
passed: this.scenarios.filter((s) => s.status === 'passed').length,
failed: this.scenarios.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
skipped: this.scenarios.filter((s) => s.status === 'skipped').length,
narrated: this.scenarios.filter((s) => s.steps.some((step) => step.facts)).length,
screenshots: this.scenarios.reduce(
(n, s) => n + s.steps.filter((step) => step.shot).length,
0,
),
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(
join(OUT_DIR, 'report.json'),
JSON.stringify({ summary, scenarios: this.scenarios }, null, 2),
'utf8',
);
writeFileSync(join(OUT_DIR, 'report.md'), renderMarkdown(summary, this.scenarios), 'utf8');
writeFileSync(
join(OUT_DIR, 'index.html'),
renderHtml(summary, this.scenarios, this.meta),
'utf8',
);
// An absolute file:// URL, because a relative path is ambiguous: the suite
// runs with its cwd in e2e/, but people launch it from the repo root.
const file = join(OUT_DIR, 'index.html');
const url = pathToFileURL(file).href;
console.log(
[
'',
'Evidence report — what passed, with data and screenshots:',
` ${url}`,
` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` +
`${summary.narrated} scenarios with recorded data`,
'',
' Open it: npm run report',
' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href,
'',
" (`npx playwright show-report` opens Playwright's own report, which is a",
' different artifact — it is for debugging failures.)',
].join('\n'),
);
}
}
// --- helpers ---------------------------------------------------------------
function slug(value: string): string {
return value.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 60);
}
function safeRead(path: string): string {
try {
return readFileSync(path, 'utf8');
} catch {
return '';
}
}
// --- markdown --------------------------------------------------------------
function renderMarkdown(summary: Summary, scenarios: Scenario[]): string {
const lines: string[] = [
'# TravelHub end-to-end run report',
'',
`Generated ${summary.generatedAt} · ${summary.passed}/${summary.total} passed · ` +
`${friendly(summary.durationMs)}`,
'',
];
for (const file of group(scenarios)) {
lines.push(`## ${areaFor(file.name).title}`, '', `_${areaFor(file.name).blurb}_`, '');
for (const scenario of file.items) {
const mark = scenario.status === 'passed' ? '✅' : scenario.status === 'skipped' ? '⏭️' : '❌';
const heading = sentenceCase(scenario.title);
lines.push(`### ${mark} ${heading}`, '');
lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, '');
if (scenario.steps.length === 0) {
lines.push('_No evidence captured._', '');
continue;
}
for (const step of scenario.steps) {
lines.push(`**${step.seq}. ${step.name}**`, '');
if (step.facts) {
lines.push('| Field | Value |', '| ----- | ----- |');
for (const [key, value] of Object.entries(step.facts)) {
lines.push(`| ${label(key)} | ${formatValue(value)} |`);
}
lines.push('');
}
if (step.shot) {
lines.push(`![${step.name}](${step.shot})`, '');
}
}
}
}
return lines.join('\n');
}
+483
View File
@@ -0,0 +1,483 @@
/**
* Renders the run report as a single self-contained page.
*
* Written for someone who did not run the tests: a verdict first, then a
* summary by capability, then the detail. Numbered sections so it can be cited
* ("2.3.1 failed"), and a print stylesheet so it can be saved as a PDF.
*/
import {
Area,
RunMeta,
Scenario,
Step,
Summary,
areaFor,
escapeHtml,
formatValue,
friendly,
group,
label,
sentenceCase,
statusClass,
statusWord,
} from './report-model';
interface AreaBlock {
index: number;
area: Area;
items: Scenario[];
passed: number;
failed: number;
}
export function renderHtml(summary: Summary, scenarios: Scenario[], meta: RunMeta): string {
const areas: AreaBlock[] = group(scenarios).map((entry, index) => ({
index: index + 1,
area: areaFor(entry.name),
items: entry.items,
passed: entry.items.filter((s) => s.status === 'passed').length,
failed: entry.items.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
}));
const allPassed = summary.failed === 0 && summary.passed > 0;
const stamp = new Date(summary.generatedAt).toLocaleString('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
});
const contents = areas
.map(
(a) =>
`<li><a href="#area-${a.index}"><span class="num">2.${a.index}</span>${escapeHtml(
a.area.title,
)}</a><span class="dots"></span><span class="count">${a.items.length} tests</span></li>`,
)
.join('');
const summaryRows = areas
.map((a) => {
const rate = Math.round((a.passed / Math.max(a.items.length, 1)) * 100);
return `
<tr>
<td class="num-cell">2.${a.index}</td>
<td><a href="#area-${a.index}">${escapeHtml(a.area.title)}</a></td>
<td class="figure">${a.items.length}</td>
<td class="figure">${a.passed}</td>
<td class="figure">${a.failed || '—'}</td>
<td>
<div class="bar" title="${rate}% passed"><span style="width:${rate}%"></span></div>
</td>
<td><span class="tag ${a.failed ? 'fail' : 'pass'}">${a.failed ? 'Failed' : 'Passed'}</span></td>
</tr>`;
})
.join('');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TravelHub — Test Execution Report</title>
<style>
${STYLES}
</style>
</head>
<body>
<div class="sheet">
<div class="cover">
<div class="eyebrow">Test execution report</div>
<h1>TravelHub</h1>
<p class="when">Multi-tenant flight booking &middot; end-to-end suite<br />${escapeHtml(stamp)}</p>
<div class="verdict ${allPassed ? 'ok' : 'bad'}">
<span class="mark">${allPassed ? '&#10003;' : '!'}</span>
<span>
<span class="headline">${
allPassed ? 'All tests passed' : `${summary.failed} of ${summary.total} tests failed`
}</span><br />
<span class="sub">${summary.passed} of ${summary.total} scenarios verified in ${friendly(
summary.durationMs,
)}</span>
</span>
</div>
<dl class="facts-grid">
<div><dt>Application under test</dt><dd>${escapeHtml(meta.baseURL)}</dd></div>
<div><dt>Browsers</dt><dd>${escapeHtml(meta.projects.join(', ') || 'chromium')}</dd></div>
<div><dt>Scenarios executed</dt><dd>${summary.total}</dd></div>
<div><dt>Duration</dt><dd>${friendly(summary.durationMs)}</dd></div>
<div><dt>Screenshots captured</dt><dd>${summary.screenshots}</dd></div>
<div><dt>Scenarios with recorded data</dt><dd>${summary.narrated}</dd></div>
</dl>
</div>
<section>
<div class="section-title"><span class="num">1</span><h2>Summary</h2></div>
<p class="lede">
Each area below is a capability of the product rather than a source file. Every
scenario was executed against the running application and a real database, except
where an area states that the backend was simulated.
</p>
<table class="summary">
<thead>
<tr>
<th></th><th>Area tested</th><th class="figure">Tests</th>
<th class="figure">Passed</th><th class="figure">Failed</th>
<th>Pass rate</th><th>Result</th>
</tr>
</thead>
<tbody>${summaryRows}</tbody>
<tfoot>
<tr>
<td></td><td>Total</td>
<td class="figure">${summary.total}</td>
<td class="figure">${summary.passed}</td>
<td class="figure">${summary.failed || '—'}</td>
<td></td>
<td><span class="tag ${allPassed ? 'pass' : 'fail'}">${allPassed ? 'Passed' : 'Failed'}</span></td>
</tr>
</tfoot>
</table>
<h3 class="sub-head">Contents</h3>
<ol class="toc">${contents}</ol>
</section>
<section>
<div class="section-title"><span class="num">2</span><h2>Detailed results</h2></div>
<p class="lede">
Scenarios shown with a data table carry the figures the test actually observed —
fares, booking references, seat counts — next to a screenshot of the screen at that
moment. The rest carry a screenshot of their final state.
</p>
<div class="toolbar">
<button id="all" aria-pressed="true">All scenarios</button>
<button id="documented" aria-pressed="false">With recorded data only</button>
<button id="expand">Expand all</button>
<button id="collapse">Collapse all</button>
<button id="pdf" title="Opens your browser's print dialog; choose &quot;Save as PDF&quot;">Save as PDF</button>
<span class="legend">
<span><i class="swatch pass"></i>Passed</span>
<span><i class="swatch fail"></i>Failed</span>
<span><i class="swatch skip"></i>Not run</span>
</span>
</div>
${areas.map(renderArea).join('')}
</section>
<footer>
Generated automatically by the TravelHub end-to-end suite
(<code>npm run test:e2e</code>) and reopened with <code>npm run report</code>.
Screenshots are taken at the moment each step completed; click one to enlarge.
Use your browser&rsquo;s print function to save this report as a PDF.
</footer>
</div>
<dialog id="lightbox"><img alt="" /></dialog>
<script>
${SCRIPT}
</script>
</body>
</html>`;
}
function renderArea(entry: AreaBlock): string {
const scenarios = entry.items
.map((scenario, i) => renderScenario(scenario, `2.${entry.index}.${i + 1}`))
.join('');
return `
<div class="area" id="area-${entry.index}">
<div class="area-head">
<div class="section-title">
<span class="num">2.${entry.index}</span>
<h2 class="area-title">${escapeHtml(entry.area.title)}</h2>
<span class="tag ${entry.failed ? 'fail' : 'pass'}">${entry.passed}/${entry.items.length} passed</span>
</div>
<p class="lede area-blurb">${escapeHtml(entry.area.blurb)}</p>
</div>
${scenarios}
</div>`;
}
function renderScenario(scenario: Scenario, ref: string): string {
const hasFacts = scenario.steps.some((s) => s.facts);
const steps =
scenario.steps.length === 0
? '<p class="no-evidence">No screenshot was captured for this scenario.</p>'
: scenario.steps.map(renderStep).join('');
return `
<details class="scenario" data-facts="${hasFacts ? '1' : '0'}"${hasFacts ? ' open' : ''}>
<summary>
<span class="ref">${ref}</span>
<span class="headline">
${scenario.suite ? `<span class="group">${escapeHtml(scenario.suite)}</span>` : ''}
<span class="what">${escapeHtml(sentenceCase(scenario.title))}</span>
</span>
<span class="right">
<span class="hide-narrow">${escapeHtml(scenario.project)}</span>
<span>${friendly(scenario.durationMs)}</span>
<span class="tag ${statusClass(scenario.status)}">${statusWord(scenario.status)}</span>
</span>
</summary>
<div class="body">
${scenario.error ? `<pre class="error">${escapeHtml(scenario.error)}</pre>` : ''}
${steps}
</div>
</details>`;
}
function renderStep(step: Step): string {
const facts = step.facts
? `<table class="facts">${Object.entries(step.facts)
.map(
([key, value]) =>
`<tr><td>${escapeHtml(label(key))}</td><td>${escapeHtml(formatValue(value))}</td></tr>`,
)
.join('')}</table>`
: '';
const shot = step.shot
? `<figure>
<img class="shot" loading="lazy" src="${escapeHtml(step.shot)}" alt="${escapeHtml(step.name)}" />
<figcaption>Screen at this point<span class="hint"> &middot; click to enlarge</span></figcaption>
</figure>`
: '';
return `
<div class="step">
<h4><span class="n">${escapeHtml(step.seq)}</span>${escapeHtml(step.name)}</h4>
${facts}
${shot}
</div>`;
}
const STYLES = `
:root {
--ink:#111827; --body:#374151; --muted:#6b7280; --faint:#9ca3af;
--line:#e5e7eb; --rule:#d1d5db; --bg:#f3f4f6; --card:#fff;
--pass:#067a6e; --pass-bg:#e6f4f1; --fail:#b3243f; --fail-bg:#fdeaee;
--skip:#6b7280; --skip-bg:#f3f4f6; --accent:#0b5cd6;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--body);
font:15px/1.6 'Inter',-apple-system,'Segoe UI',Roboto,sans-serif}
.sheet{max-width:1080px;margin:0 auto;background:var(--card);
box-shadow:0 1px 3px rgba(0,0,0,.08)}
h1,h2,h3,h4{color:var(--ink);margin:0;line-height:1.3}
a{color:var(--accent)}
.cover{padding:44px 56px 36px;border-bottom:3px solid var(--ink)}
.eyebrow{font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;
color:var(--faint);font-weight:650;margin-bottom:10px}
.cover h1{font-size:2.05rem;letter-spacing:-.025em;margin-bottom:6px}
.cover .when{color:var(--muted);font-size:.92rem;margin:0}
.verdict{display:flex;align-items:center;gap:18px;margin-top:28px;padding:20px 24px;
border-radius:10px;border:1px solid}
.verdict.ok{background:var(--pass-bg);border-color:#b9e0d8}
.verdict.bad{background:var(--fail-bg);border-color:#f3c2cc}
.verdict .mark{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;
font-size:1.3rem;color:#fff;flex-shrink:0}
.verdict.ok .mark{background:var(--pass)}
.verdict.bad .mark{background:var(--fail)}
.verdict .headline{font-size:1.16rem;font-weight:650;color:var(--ink)}
.verdict .sub{font-size:.9rem;color:var(--muted)}
.facts-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));
border-top:1px solid var(--line);margin:28px 0 0}
.facts-grid div{padding:14px 24px 14px 0;border-bottom:1px solid var(--line)}
.facts-grid dt{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--faint);font-weight:650;margin-bottom:3px}
.facts-grid dd{margin:0;font-size:.92rem;color:var(--ink)}
section{padding:36px 56px}
section + section{border-top:1px solid var(--line)}
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:6px;flex-wrap:wrap}
.section-title .num{color:var(--faint);font-variant-numeric:tabular-nums;
font-weight:650;font-size:1.05rem}
.section-title h2{font-size:1.3rem;letter-spacing:-.015em}
.area-title{font-size:1.12rem}
.lede{color:var(--muted);font-size:.93rem;margin:0 0 22px;max-width:74ch}
.area-blurb{margin:8px 0 0}
.sub-head{margin-top:30px;font-size:.95rem}
.toc{list-style:none;padding:0;margin:10px 0 0;max-width:640px}
.toc li{display:flex;align-items:baseline;gap:8px;padding:7px 0;
border-bottom:1px dotted var(--rule)}
.toc a{text-decoration:none;color:var(--ink)}
.toc a:hover{color:var(--accent)}
.toc .num{color:var(--faint);font-variant-numeric:tabular-nums;margin-right:10px}
.toc .dots{flex:1}
.toc .count{color:var(--faint);font-size:.85rem;font-variant-numeric:tabular-nums}
table.summary{width:100%;border-collapse:collapse;font-size:.92rem}
table.summary th{text-align:left;font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--faint);font-weight:650;padding:0 12px 8px 0;
border-bottom:2px solid var(--rule)}
table.summary td{padding:11px 12px 11px 0;border-bottom:1px solid var(--line);
vertical-align:middle}
table.summary td a{text-decoration:none;font-weight:550}
.num-cell{color:var(--faint);font-variant-numeric:tabular-nums;width:48px}
.figure{font-variant-numeric:tabular-nums;text-align:right;width:70px}
.bar{width:110px;height:7px;background:var(--line);border-radius:99px;overflow:hidden}
.bar span{display:block;height:100%;background:var(--pass);border-radius:99px}
.tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
text-transform:uppercase;padding:3px 9px;border-radius:4px;white-space:nowrap}
.tag.pass{background:var(--pass-bg);color:var(--pass)}
.tag.fail{background:var(--fail-bg);color:var(--fail)}
.tag.skip{background:var(--skip-bg);color:var(--skip)}
tfoot td{font-weight:650;color:var(--ink);border-bottom:none;padding-top:14px}
.area{padding-top:34px}
.area:first-of-type{padding-top:0}
.area-head{border-left:3px solid var(--accent);padding-left:16px;margin-bottom:20px}
.scenario{border:1px solid var(--line);border-radius:8px;margin-bottom:14px;
overflow:hidden;background:var(--card)}
.scenario > summary{list-style:none;cursor:pointer;padding:13px 16px;display:flex;
gap:14px;align-items:flex-start}
.scenario > summary::-webkit-details-marker{display:none}
.scenario > summary:hover{background:#fafafa}
.ref{color:var(--faint);font-variant-numeric:tabular-nums;font-size:.85rem;
min-width:52px;flex-shrink:0;padding-top:2px}
.headline{flex:1;min-width:0}
.headline .what{font-weight:600;color:var(--ink)}
.headline .group{display:block;font-size:.78rem;color:var(--muted);margin-bottom:1px}
.right{display:flex;align-items:center;gap:10px;flex-shrink:0;
font-size:.78rem;color:var(--faint)}
.body{border-top:1px solid var(--line);padding:0 16px 8px;background:#fcfcfd}
.step{padding:18px 0;border-bottom:1px dashed var(--line)}
.step:last-child{border-bottom:none}
.step h4{font-size:.95rem;margin-bottom:10px;display:flex;gap:9px;align-items:baseline}
.step h4 .n{color:var(--faint);font-variant-numeric:tabular-nums;font-weight:650;font-size:.8rem}
table.facts{border-collapse:collapse;font-size:.86rem;margin:0 0 12px;min-width:340px}
table.facts td{border:1px solid var(--line);padding:6px 11px}
table.facts td:first-child{background:#f8fafc;color:var(--muted);white-space:nowrap;
font-size:.8rem}
table.facts td:last-child{font-variant-numeric:tabular-nums;color:var(--ink);font-weight:500}
figure{margin:0}
.shot{display:block;max-width:100%;border:1px solid var(--rule);border-radius:6px;
cursor:zoom-in}
figcaption{font-size:.75rem;color:var(--faint);margin-top:5px}
.no-evidence{color:var(--faint);font-size:.86rem;padding:14px 0}
.error{background:var(--fail-bg);color:#8c1030;padding:11px 14px;margin:12px 0;
border-radius:6px;font-size:.83rem;white-space:pre-wrap;
font-family:ui-monospace,monospace}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:20px}
.toolbar button{border:1px solid var(--rule);background:#fff;border-radius:6px;
padding:6px 13px;font:inherit;font-size:.85rem;cursor:pointer;color:var(--body)}
.toolbar button:hover{border-color:var(--accent);color:var(--accent)}
.toolbar button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:.8rem;color:var(--muted);margin-left:auto}
.legend span{display:inline-flex;align-items:center;gap:6px}
.swatch{width:10px;height:10px;border-radius:2px;display:inline-block}
.swatch.pass{background:var(--pass)}
.swatch.fail{background:var(--fail)}
.swatch.skip{background:var(--skip)}
footer{padding:26px 56px 44px;color:var(--faint);font-size:.82rem;
border-top:1px solid var(--line)}
footer code{background:var(--bg);padding:1px 5px;border-radius:3px}
dialog{border:none;border-radius:10px;padding:0;max-width:96vw;max-height:96vh}
dialog::backdrop{background:rgba(17,24,39,.8)}
dialog img{display:block;max-width:96vw;max-height:96vh}
@media (max-width:720px){
.cover{padding:28px 20px 24px}
section{padding:26px 20px}
footer{padding:22px 20px 34px}
.hide-narrow{display:none}
.scenario > summary{flex-wrap:wrap}
table.summary{font-size:.85rem}
.bar{width:60px}
}
@media print{
body{background:#fff;font-size:11pt}
.sheet{box-shadow:none;max-width:none}
.toolbar,dialog{display:none !important}
/* The page margins come from the printer, so drop the screen gutters. */
.cover{padding:0 0 22px}
section{padding:22px 0}
footer{padding:16px 0 0}
.area{break-before:page}
.area:first-of-type{break-before:auto}
.area-head{break-after:avoid}
.scenario{break-inside:avoid;border-color:var(--rule)}
.scenario > summary{background:#fff}
.step{break-inside:avoid}
h2,h3,h4{break-after:avoid}
table.summary{break-inside:avoid}
/* A closed <details> prints nothing, which would silently drop most of the
report. The beforeprint handler opens them; this is the belt and braces. */
.scenario:not([open]) > .body{display:block}
.shot{max-height:150mm;object-fit:contain;object-position:left top}
a,a:visited,table.summary td a,.toc a{color:var(--ink);text-decoration:none}
.toc .count,.right{color:var(--muted)}
.hint{display:none} /* nothing to click on paper */
}`;
const SCRIPT = `
const box = document.getElementById('lightbox');
const img = box.querySelector('img');
document.addEventListener('click', (e) => {
if (e.target instanceof HTMLImageElement && e.target.classList.contains('shot')) {
img.src = e.target.src;
box.showModal();
} else if (e.target === box) {
box.close();
}
});
const all = document.getElementById('all');
const documented = document.getElementById('documented');
function filter(onlyDocumented) {
all.setAttribute('aria-pressed', String(!onlyDocumented));
documented.setAttribute('aria-pressed', String(onlyDocumented));
for (const el of document.querySelectorAll('.scenario')) {
el.hidden = onlyDocumented && el.dataset.facts === '0';
}
for (const area of document.querySelectorAll('.area')) {
area.hidden = ![...area.querySelectorAll('.scenario')].some((s) => !s.hidden);
}
}
all.addEventListener('click', () => filter(false));
documented.addEventListener('click', () => filter(true));
document.getElementById('expand').addEventListener('click', () =>
document.querySelectorAll('.scenario').forEach((d) => (d.open = true)));
document.getElementById('collapse').addEventListener('click', () =>
document.querySelectorAll('.scenario').forEach((d) => (d.open = false)));
document.getElementById('pdf').addEventListener('click', () => window.print());
// Printing a collapsed <details> prints only its summary, so open everything
// first and put it back the way the reader had it afterwards.
let restore = [];
window.addEventListener('beforeprint', () => {
restore = [...document.querySelectorAll('.scenario')].map((d) => [d, d.open]);
for (const [d] of restore) {
d.open = true;
}
for (const img of document.querySelectorAll('img.shot')) {
img.loading = 'eager';
}
});
window.addEventListener('afterprint', () => {
for (const [d, was] of restore) {
d.open = was;
}
});`;
+200
View File
@@ -0,0 +1,200 @@
/**
* The shape of a run report, and the vocabulary it is written in.
*
* Kept separate from both the collector and the renderers so neither depends on
* the other: `evidence.ts` gathers, `render-html.ts` presents, and both agree
* on what a scenario is by importing from here.
*/
import type { TestResult } from '@playwright/test/reporter';
/** One captured moment inside a scenario. */
export interface Step {
seq: string;
name: string;
shot?: string; // path relative to the report root
facts?: Record<string, unknown>;
}
/** One test, as the report talks about it. */
export interface Scenario {
project: string;
file: string;
suite: string;
title: string;
status: TestResult['status'];
expected: boolean;
durationMs: number;
steps: Step[];
error?: string;
}
/** How the run was configured, for the cover page. */
export interface RunMeta {
baseURL: string;
projects: string[];
workers: number;
}
export interface Summary {
generatedAt: string;
outcome: string;
durationMs: number;
total: number;
passed: number;
failed: number;
skipped: number;
narrated: number;
screenshots: number;
}
// --- areas -----------------------------------------------------------------
/**
* Spec filenames are an implementation detail. The report groups by what the
* tests actually cover, so somebody who has never opened the codebase can read
* it without a glossary.
*/
export interface Area {
title: string;
blurb: string;
}
export const AREAS: Record<string, Area> = {
'auth.spec.ts': {
title: 'Signing in and sessions',
blurb:
'Agents sign in against their own agency. Credentials issued by one agency must not work at another, and a session should survive a page reload but not outlive a sign-out.',
},
'search.spec.ts': {
title: 'Searching for flights',
blurb:
'Filtering the agency inventory by route, date, cabin and party size; ordering results by fare, journey time or departure; and paging through them.',
},
'booking.spec.ts': {
title: 'Booking and cancelling',
blurb:
'Selling a ticket end to end: the fare carried into checkout, traveller details, confirmation with a booking reference, and cancellation returning the seats to inventory.',
},
'tenant-isolation.spec.ts': {
title: 'Keeping agencies separate',
blurb:
'The central promise of the platform. Two agencies share one deployment and one database, and neither may see the other’s inventory, customers or revenue — not even by guessing a web address.',
},
'dashboard.spec.ts': {
title: 'Agency dashboard',
blurb:
'Trading figures for the signed-in agency — revenue, seats sold, bookings and best-selling routes — and that they move correctly when a sale is made and when it is cancelled.',
},
'simulation.spec.ts': {
title: 'Behaviour when things go wrong',
blurb:
'Here the backend is simulated so it can misbehave on demand: server errors, slow responses, a dropped network, an expired session, a seat sold mid-purchase, and awkward data such as very long names.',
},
'responsive.spec.ts': {
title: 'Phones and small screens',
blurb:
'The console is used at a desk and on a phone. No screen may scroll sideways at phone width, and wide tables must scroll inside their own frame rather than stretching the page.',
},
};
/** Reading order, rather than alphabetical: a reader follows the user journey. */
export const AREA_ORDER = [
'auth.spec.ts',
'search.spec.ts',
'booking.spec.ts',
'tenant-isolation.spec.ts',
'dashboard.spec.ts',
'simulation.spec.ts',
'responsive.spec.ts',
];
export function areaFor(file: string): Area {
const name = file.split('/').pop() ?? file;
return AREAS[name] ?? { title: name, blurb: '' };
}
export function group(scenarios: Scenario[]): Array<{ name: string; items: Scenario[] }> {
const map = new Map<string, Scenario[]>();
for (const scenario of scenarios) {
const list = map.get(scenario.file) ?? [];
list.push(scenario);
map.set(scenario.file, list);
}
const rank = (file: string) => {
const index = AREA_ORDER.indexOf(file.split('/').pop() ?? file);
return index === -1 ? AREA_ORDER.length : index;
};
return [...map.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([name, items]) => ({ name, items }));
}
// --- formatting ------------------------------------------------------------
export function escapeHtml(value: unknown): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
/** "1 min 30 s" reads better in a summary than "89.8 s". */
export function friendly(ms: number): string {
if (ms < 1000) {
return `${ms} ms`;
}
const total = Math.round(ms / 1000);
const minutes = Math.floor(total / 60);
const secs = total % 60;
return minutes === 0 ? `${secs} s` : `${minutes} min ${String(secs).padStart(2, '0')} s`;
}
export function sentenceCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
/** seatsAvailable -> "Seats available", so data tables read as English. */
export function label(key: string): string {
const spaced = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.toLowerCase()
.trim();
return sentenceCase(spaced);
}
export function statusWord(status: Scenario['status']): string {
switch (status) {
case 'passed':
return 'Passed';
case 'skipped':
return 'Not run';
case 'timedOut':
return 'Timed out';
default:
return 'Failed';
}
}
export function statusClass(status: Scenario['status']): string {
if (status === 'passed') {
return 'pass';
}
return status === 'skipped' ? 'skip' : 'fail';
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* Exports the evidence report to PDF, headlessly.
*
* Uses the Chromium that Playwright already installed, so there is no new
* dependency and no need to open a browser by hand. The browser's own
* File > Print also works and produces the same layout; this exists so the
* export can be scripted, and so CI can attach a PDF to a build.
*
* node scripts/report-pdf.mjs everything
* node scripts/report-pdf.mjs --summary summary plus the narrated scenarios only
* node scripts/report-pdf.mjs --out x.pdf write somewhere else
*/
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromium } from '@playwright/test';
const E2E = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPORT_DIR = join(E2E, 'evidence-report');
const SOURCE = join(REPORT_DIR, 'index.html');
const args = process.argv.slice(2);
const summaryOnly = args.includes('--summary');
const outFlag = args.indexOf('--out');
const requested = outFlag !== -1 ? args[outFlag + 1] : undefined;
const OUT = requested
? isAbsolute(requested)
? requested
: resolve(process.cwd(), requested)
: join(REPORT_DIR, summaryOnly ? 'TravelHub-test-report-summary.pdf' : 'TravelHub-test-report.pdf');
if (!existsSync(SOURCE)) {
console.error(
`No report found at ${SOURCE}\n` + 'Run the suite first: npm run test:e2e',
);
process.exit(1);
}
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto(pathToFileURL(SOURCE).href, { waitUntil: 'load' });
const counts = await page.evaluate(async (opts) => {
if (opts.summaryOnly) {
// Drop scenarios that carry only a screenshot, then any area left empty.
for (const el of document.querySelectorAll('.scenario')) {
if (el.dataset.facts === '0') {
el.remove();
}
}
for (const area of document.querySelectorAll('.area')) {
if (!area.querySelector('.scenario')) {
area.remove();
}
}
}
// A collapsed <details> would print as a one-line summary.
for (const details of document.querySelectorAll('details')) {
details.open = true;
}
// Lazy images never load off-screen, and the PDF renderer does not scroll.
// Only the screenshots: the lightbox holds an empty <img> that never loads.
const images = [...document.querySelectorAll('img.shot')];
for (const img of images) {
img.loading = 'eager';
}
await Promise.all(
images.map((img) =>
img.complete
? null
: new Promise((done) => {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}),
),
);
return {
scenarios: document.querySelectorAll('.scenario').length,
images: images.length,
unloaded: images.filter((i) => i.naturalWidth === 0).length,
};
}, { summaryOnly });
if (counts.unloaded > 0) {
console.warn(` warning: ${counts.unloaded} screenshot(s) failed to load`);
}
await page.emulateMedia({ media: 'print' });
const foot =
'<div style="width:100%;font-size:8pt;color:#9ca3af;padding:0 12mm;' +
'font-family:Segoe UI,Helvetica,Arial,sans-serif;display:flex;justify-content:space-between">' +
'<span>TravelHub — test execution report</span>' +
'<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>' +
'</div>';
mkdirSync(dirname(OUT), { recursive: true });
await page.pdf({
path: OUT,
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<div></div>', // suppress Chromium's default header
footerTemplate: foot,
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
});
const mb = (statSync(OUT).size / 1024 / 1024).toFixed(1);
console.log(
`PDF written: ${OUT}\n` +
` ${counts.scenarios} scenarios · ${counts.images} screenshots · ${mb} MB` +
(summaryOnly ? ' (summary: narrated scenarios only)' : ''),
);
} finally {
await browser.close();
}
+155
View File
@@ -0,0 +1,155 @@
import { expect, test } from '../fixtures/test';
import { GLOBETROTTER_AGENT, SKYLINE_ADMIN, SKYLINE_AGENT } from '../fixtures/accounts';
test.describe('Sign in', () => {
test('offers every active agency in the picker', async ({ login }) => {
await login.goto();
// One placeholder option plus the seeded agencies.
await expect(login.tenantSelect.locator('option')).toHaveCount(3);
await expect(login.tenantSelect.locator('option')).toContainText([
'Select an agency',
'Globetrotter Tours',
'Skyline Travel',
]);
});
test('a demo shortcut fills the whole form', async ({ login }) => {
await login.goto();
await login.useDemoChip(SKYLINE_AGENT);
await expect(login.tenantSelect).toHaveValue('skyline');
await expect(login.email).toHaveValue(SKYLINE_AGENT.email);
await expect(login.password).toHaveValue(SKYLINE_AGENT.password);
});
test('signs an agent into their own agency', async ({ login, shell, page, evidence }) => {
await login.goto();
await evidence.step('Sign-in screen with the agency picker', {
agenciesOffered: (await login.tenantSelect.locator('option').count()) - 1,
demoShortcuts: await login.demoChips.count(),
});
await login.signIn(SKYLINE_AGENT);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
await expect(shell.accountName).toHaveText(SKYLINE_AGENT.fullName);
await expect(shell.accountMeta).toContainText(SKYLINE_AGENT.email);
await evidence.step('Signed in to the agency console', {
agency: SKYLINE_AGENT.tenantName,
user: SKYLINE_AGENT.fullName,
email: SKYLINE_AGENT.email,
role: SKYLINE_AGENT.role,
landedOn: '/dashboard',
});
});
test('signs an admin in and shows the admin role', async ({ login, shell }) => {
await login.signIn(SKYLINE_ADMIN);
await expect(shell.accountName).toHaveText(SKYLINE_ADMIN.fullName);
await expect(shell.accountMeta).toContainText('admin');
});
test('rejects a wrong password', async ({ login }) => {
await login.goto();
const message = await login.signInExpectingFailure(SKYLINE_AGENT, 'definitely-wrong');
expect(message).toContain('not valid for this agency');
await expect(login.submit).toBeVisible();
});
// The central multi-tenant rule, exercised through the UI.
test('rejects valid credentials aimed at the wrong agency', async ({ login }) => {
await login.goto();
await login.tenantSelect.selectOption(GLOBETROTTER_AGENT.tenant);
await login.email.fill(SKYLINE_AGENT.email);
await login.password.fill(SKYLINE_AGENT.password);
await login.submit.click();
await expect(login.error).toContainText('not valid for this agency');
await expect(login.submit).toBeVisible();
});
test('will not submit without an agency', async ({ login, page }) => {
await login.goto();
await login.email.fill(SKYLINE_AGENT.email);
await login.password.fill(SKYLINE_AGENT.password);
await login.submit.click();
await expect(page.locator('.field .error')).toContainText('choose an agency');
await expect(page).toHaveURL(/\/login/);
});
test('rejects a malformed email before contacting the server', async ({ login, page }) => {
await login.goto();
await login.tenantSelect.selectOption(SKYLINE_AGENT.tenant);
await login.email.fill('not-an-email');
await login.password.fill('whatever');
await login.submit.click();
await expect(page.locator('.field .error')).toContainText('valid email');
});
});
test.describe('Session', () => {
test('survives a page reload', async ({ login, shell, page }) => {
await login.signIn(SKYLINE_AGENT);
await page.reload();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
});
test('sends an anonymous visitor to sign-in and back again', async ({ login, page }) => {
await page.goto('/bookings');
// The guard should bounce to the sign-in screen and remember the target.
await expect(page).toHaveURL(/\/login\?returnTo=%2Fbookings/);
await login.useDemoChip(SKYLINE_AGENT);
await login.submit.click();
await expect(page).toHaveURL(/\/bookings$/);
});
test('signing out clears the session for good', async ({ login, shell, page }) => {
await login.signIn(SKYLINE_AGENT);
await shell.signOutAndWait();
await expect(page).toHaveURL(/\/login$/);
// Going back must not resurrect the console.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
test('an already signed-in user is moved off the sign-in screen', async ({ login, page }) => {
await login.signIn(SKYLINE_AGENT);
await page.goto('/login');
await expect(page).toHaveURL(/\/dashboard$/);
});
test('stale stored credentials are discarded', async ({ login, page }) => {
await login.signIn(SKYLINE_AGENT);
// Corrupt the stored password the way a changed account would.
await page.evaluate(() => {
localStorage.setItem(
'travelhub.credentials',
JSON.stringify({ tenant: 'skyline', basic: btoa('agent@skyline.test:stale') }),
);
});
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
const stored = await page.evaluate(() => localStorage.getItem('travelhub.credentials'));
expect(stored).toBeNull();
});
});
+262
View File
@@ -0,0 +1,262 @@
import { expect, test, uniqueSurname } from '../fixtures/test';
import { SKYLINE_AGENT } from '../fixtures/accounts';
test.beforeEach(async ({ signInAs, shell, search }) => {
await signInAs(SKYLINE_AGENT);
await shell.goToSearch();
await search.settled();
});
test.describe('Booking a flight', () => {
test('carries the fare and traveller count into checkout', async ({ search, checkout, page }) => {
await search.searchRoute('JFK', 'LHR');
await search.setTravellers(2);
const fare = await search.card(0).fare();
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await expect(checkout.travellerFieldsets).toHaveCount(2);
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
});
test('reprices as travellers are added and removed', async ({ search, checkout, page }) => {
const fare = await search.card(0).fare();
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
await checkout.addTraveller.click();
await expect(checkout.travellerFieldsets).toHaveCount(2);
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
await checkout.removeTraveller(1);
await expect(checkout.travellerFieldsets).toHaveCount(1);
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
});
test('refuses to submit with missing traveller names', async ({ search, checkout, page }) => {
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.confirm.click();
await expect(checkout.error).toBeVisible();
await expect(page.locator('.pax .error').first()).toContainText('Required');
// Still on the form, nothing sold.
await expect(page).toHaveURL(/\/book\//);
});
test('books, confirms, lists and cancels, returning the seats', async ({
search,
checkout,
bookings,
bookingDetail,
shell,
page,
evidence,
}) => {
const surname = uniqueSurname();
await search.searchRoute('JFK', 'LHR');
const card = search.card(0);
const flightNumber = await card.flightNumber();
const fare = await card.fare();
const seatsBefore = await card.seatsLeft();
await evidence.step('Search results: JFK to LHR', {
agency: 'Skyline Travel',
route: 'JFK to LHR',
matchingFlights: await search.totalResults(),
cheapestFlight: flightNumber,
fare: `$${fare.toFixed(2)}`,
seatsAvailable: seatsBefore,
});
await card.selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTravellers([
{ firstName: 'Dana', lastName: surname, dateOfBirth: '1988-04-12', passportNumber: 'X1234567' },
{ firstName: 'Elias', lastName: surname },
]);
await checkout.contactPhone.fill('+1-555-0101');
const expectedTotal = await checkout.totalAmount();
await evidence.step('Checkout completed for two travellers', {
travellers: `Dana ${surname}, Elias ${surname}`,
farePerTraveller: `$${fare.toFixed(2)}`,
total: `$${expectedTotal.toFixed(2)}`,
contactPhone: '+1-555-0101',
});
await checkout.confirmBooking();
// --- confirmation -------------------------------------------------------
await expect(bookingDetail.confirmationBanner).toBeVisible();
await expect(bookingDetail.confirmationBanner).toContainText('Booking confirmed');
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
await expect(bookingDetail.travellerRows).toHaveCount(2);
await expect(bookingDetail.travellerRows.first()).toContainText(surname);
const reference = await bookingDetail.bookingReference();
expect(reference).toMatch(/^[A-Z0-9]{6}$/);
expect(Number((await bookingDetail.totalPaid.innerText()).replace(/[^0-9.]/g, ''))).toBeCloseTo(
expectedTotal,
2,
);
expect(expectedTotal).toBeCloseTo(fare * 2, 2);
await evidence.step('Booking confirmed', {
bookingReference: reference,
status: 'confirmed',
travellers: 2,
totalPaid: `$${expectedTotal.toFixed(2)}`,
soldBy: SKYLINE_AGENT.fullName,
});
// --- appears in the list ------------------------------------------------
await shell.goToBookings();
await expect(bookings.rowFor(reference)).toHaveCount(1);
await expect(bookings.rowFor(reference)).toContainText(/confirmed/i);
await expect(bookings.rowFor(reference)).toContainText(SKYLINE_AGENT.fullName);
await evidence.step('Booking appears in the agency list', {
bookingReference: reference,
visibleInList: true,
});
// --- seats were taken out of inventory ----------------------------------
await shell.goToSearch();
await search.searchRoute('JFK', 'LHR');
const seatsAfterSale = await search.cardForFlight(flightNumber).seatsLeft();
expect(seatsAfterSale).toBe(seatsBefore - 2);
await evidence.step('Seats removed from inventory', {
flight: flightNumber,
seatsBefore,
seatsAfterSale,
difference: seatsBefore - seatsAfterSale,
});
// --- cancel -------------------------------------------------------------
await shell.goToBookings();
await bookings.open(reference);
await bookingDetail.cancel(reference);
await expect(bookingDetail.statusBadge).toHaveText(/cancelled/i);
await expect(bookingDetail.cancelledNotice).toContainText('returned to inventory');
await expect(bookingDetail.cancelButton).toHaveCount(0);
await evidence.step('Booking cancelled', {
bookingReference: reference,
status: 'cancelled',
});
// --- seats came back ----------------------------------------------------
await shell.goToSearch();
await search.searchRoute('JFK', 'LHR');
const seatsAfterCancel = await search.cardForFlight(flightNumber).seatsLeft();
expect(seatsAfterCancel).toBe(seatsBefore);
await evidence.step('Seats returned to inventory', {
flight: flightNumber,
seatsBefore,
seatsAfterSale,
seatsAfterCancel,
restored: seatsAfterCancel === seatsBefore,
});
});
test('a cancel can be backed out of', async ({ search, checkout, bookingDetail, page }) => {
const surname = uniqueSurname('KEEP');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTravellers([{ firstName: 'Mina', lastName: surname }]);
await checkout.confirmBooking();
const reference = await bookingDetail.bookingReference();
await bookingDetail.cancelButton.click();
await expect(bookingDetail.confirmCancelButton).toBeVisible();
await bookingDetail.keepButton.click();
await expect(bookingDetail.confirmCancelButton).toHaveCount(0);
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
// Leave the data as we found it.
await bookingDetail.cancel(reference);
});
test('the bookings list filters by status', async ({
search,
checkout,
bookings,
bookingDetail,
shell,
page,
}) => {
const surname = uniqueSurname('FILTER');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTravellers([{ firstName: 'Otto', lastName: surname }]);
await checkout.confirmBooking();
const reference = await bookingDetail.bookingReference();
await shell.goToBookings();
await bookings.filterBy('confirmed');
await expect(bookings.rowFor(reference)).toHaveCount(1);
await bookings.filterBy('cancelled');
await expect(bookings.rowFor(reference)).toHaveCount(0);
// Cancel it, then confirm it moves between the two filters.
await bookings.filterBy('all');
await bookings.open(reference);
await bookingDetail.cancel(reference);
await shell.goToBookings();
await bookings.filterBy('cancelled');
await expect(bookings.rowFor(reference)).toHaveCount(1);
await bookings.filterBy('confirmed');
await expect(bookings.rowFor(reference)).toHaveCount(0);
});
test('a booking opened directly shows no stale confirmation banner', async ({
search,
checkout,
bookingDetail,
bookings,
shell,
page,
}) => {
const surname = uniqueSurname('DIRECT');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTravellers([{ firstName: 'Rhea', lastName: surname }]);
await checkout.confirmBooking();
const reference = await bookingDetail.bookingReference();
// Re-entering from the list is not a fresh purchase.
await shell.goToBookings();
await bookings.open(reference);
await expect(bookingDetail.confirmationBanner).toHaveCount(0);
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
await bookingDetail.cancel(reference);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, uniqueSurname } from '../fixtures/test';
import { SKYLINE_AGENT } from '../fixtures/accounts';
test.beforeEach(async ({ signInAs }) => {
await signInAs(SKYLINE_AGENT);
});
test.describe('Dashboard', () => {
test('greets the agent and names the agency', async ({ dashboard }) => {
await dashboard.settled();
await expect(dashboard.greeting).toContainText('Marcus');
await expect(dashboard.greeting.locator('..')).toContainText('Skyline Travel');
});
test('shows the four headline figures', async ({ dashboard, page }) => {
await dashboard.settled();
await expect(dashboard.tiles).toHaveCount(4);
await expect(page.locator('.tile-label')).toContainText([
'Revenue',
'Bookings',
'Seats sold',
'Flights in stock',
]);
expect(await dashboard.tileValue('Flights in stock')).toBeGreaterThan(0);
});
test('lists recent bookings and best routes from the seed data', async ({ dashboard }) => {
await dashboard.settled();
expect(await dashboard.recentRows.count()).toBeGreaterThan(0);
expect(await dashboard.topRoutes.count()).toBeGreaterThan(0);
await expect(dashboard.topRoutes.first()).toContainText(' to ');
});
// Delta-based, so the test does not care what the database already held.
test('a new sale moves the figures, and cancelling moves them back', async ({
dashboard,
shell,
search,
checkout,
bookings,
bookingDetail,
page,
evidence,
}) => {
await dashboard.settled();
const revenueBefore = await dashboard.tileValue('Revenue');
const seatsBefore = await dashboard.tileValue('Seats sold');
const bookingsBefore = await dashboard.tileValue('Bookings');
await evidence.step('Dashboard before the sale', {
agency: 'Skyline Travel',
revenue: `$${revenueBefore.toLocaleString()}`,
seatsSold: seatsBefore,
bookings: bookingsBefore,
});
await shell.goToSearch();
const fare = await search.card(0).fare();
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
const surname = uniqueSurname('DASH');
await checkout.fillTravellers([
{ firstName: 'Ana', lastName: surname },
{ firstName: 'Bo', lastName: surname },
]);
await checkout.confirmBooking();
const reference = await bookingDetail.bookingReference();
await shell.goToDashboard();
await dashboard.settled();
// Revenue is rounded to whole units in the tile, so allow a unit of slack.
expect(await dashboard.tileValue('Revenue')).toBeGreaterThanOrEqual(
Math.floor(revenueBefore + fare * 2) - 1,
);
const revenueAfter = await dashboard.tileValue('Revenue');
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore + 2);
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
await expect(dashboard.recentRows.first()).toContainText(reference);
await evidence.step('Dashboard after the sale', {
bookingReference: reference,
revenueBefore: `$${revenueBefore.toLocaleString()}`,
revenueAfter: `$${revenueAfter.toLocaleString()}`,
farePerTraveller: `$${fare.toFixed(2)}`,
seatsSold: `${seatsBefore} to ${seatsBefore + 2}`,
bookings: `${bookingsBefore} to ${bookingsBefore + 1}`,
});
// --- cancel and re-check ------------------------------------------------
await shell.goToBookings();
await bookings.open(reference);
await bookingDetail.cancel(reference);
await shell.goToDashboard();
await dashboard.settled();
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore);
// The booking still exists; it is just no longer confirmed.
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
await evidence.step('Dashboard after cancelling', {
bookingReference: reference,
seatsSold: `back to ${seatsBefore}`,
bookings: `${bookingsBefore + 1} (the cancelled booking is still counted)`,
revenue: `$${(await dashboard.tileValue('Revenue')).toLocaleString()}`,
});
});
test('a recent booking row opens the booking', async ({ dashboard, page }) => {
await dashboard.settled();
await dashboard.recentRows.first().click();
await expect(page).toHaveURL(/\/bookings\/[0-9a-f-]+/);
});
test('the new booking shortcut goes to search', async ({ dashboard, page }) => {
await dashboard.settled();
await page.getByRole('link', { name: '+ New booking' }).click();
await expect(page).toHaveURL(/\/search$/);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* The console is built to work at phone width. These checks guard the layout
* rules that make that true -- chiefly that nothing forces a horizontal scroll.
*/
import { expect, test } from '../fixtures/test';
import { SKYLINE_AGENT } from '../fixtures/accounts';
/** True when the document is wider than the viewport. */
async function scrollsSideways(page: import('@playwright/test').Page): Promise<boolean> {
return page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
}
test.describe('Layout', () => {
test('the sign-in screen fits without sideways scrolling', async ({ page, login }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login.goto();
expect(await scrollsSideways(page)).toBe(false);
// The decorative panel is hidden rather than squeezed.
await expect(page.locator('.hero')).toBeHidden();
await expect(login.submit).toBeVisible();
});
test('the console screens fit at phone width', async ({ page, signInAs, shell, search }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAs(SKYLINE_AGENT);
expect(await scrollsSideways(page)).toBe(false);
await shell.goToSearch();
await search.settled();
expect(await scrollsSideways(page)).toBe(false);
await expect(search.results.first()).toBeVisible();
await shell.goToBookings();
expect(await scrollsSideways(page)).toBe(false);
});
test('wide tables scroll inside their own container', async ({ page, signInAs, shell }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAs(SKYLINE_AGENT);
await shell.goToBookings();
const wrapper = page.locator('.table-wrap').first();
await expect(wrapper).toBeVisible();
// The table may overflow; the page must not.
expect(await scrollsSideways(page)).toBe(false);
});
test('the sidebar collapses into a top bar on narrow screens', async ({ page, signInAs }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAs(SKYLINE_AGENT);
const sidebar = page.locator('.sidebar');
const content = page.locator('.content');
const sidebarBox = await sidebar.boundingBox();
const contentBox = await content.boundingBox();
expect(sidebarBox).not.toBeNull();
expect(contentBox).not.toBeNull();
// Stacked, not side by side.
expect(contentBox!.y).toBeGreaterThan(sidebarBox!.y);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from '../fixtures/test';
import { SKYLINE_AGENT } from '../fixtures/accounts';
test.beforeEach(async ({ signInAs, shell, search }) => {
await signInAs(SKYLINE_AGENT);
await shell.goToSearch();
await search.settled();
});
test.describe('Flight search', () => {
test('opens with the agency inventory already listed', async ({ search }) => {
await expect(search.results.first()).toBeVisible();
await expect(search.resultSummary).toContainText('flights');
expect(await search.totalResults()).toBeGreaterThan(0);
});
test('offers only airports this agency flies', async ({ search }) => {
const codes = await search.origin.locator('option').allInnerTexts();
expect(codes.join(' ')).toContain('JFK');
expect(codes.join(' ')).toContain('LHR');
expect(codes[0]).toContain('Any airport');
});
test('narrows results to a route', async ({ search, evidence }) => {
const before = await search.totalResults();
await evidence.step('Full inventory before filtering', { flightsVisible: before });
await search.searchRoute('JFK', 'LHR');
const after = await search.totalResults();
await evidence.step('Filtered to JFK to LHR', {
route: 'JFK to LHR',
flightsBefore: before,
flightsAfter: after,
cheapestFare: `$${(await search.card(0).fare()).toFixed(2)}`,
firstFlight: await search.card(0).flightNumber(),
});
expect(after).toBeGreaterThan(0);
expect(after).toBeLessThan(before);
// Every visible card should be on the requested route.
const cards = await search.results.all();
for (const card of cards) {
await expect(card.locator('.iata').first()).toHaveText('JFK');
await expect(card.locator('.iata').last()).toHaveText('LHR');
}
});
test('a route with no inventory shows an empty state', async ({ search }) => {
// Nothing in the seed data flies Heathrow to Heathrow.
await search.searchRoute('LHR', 'LHR');
await expect(search.emptyState).toBeVisible();
await expect(search.emptyState).toContainText('Nothing on this route yet');
await expect(search.results).toHaveCount(0);
});
test('resets back to the full list', async ({ search }) => {
await search.searchRoute('JFK', 'LHR');
const narrowed = await search.totalResults();
await search.resetButton.click();
await search.settled();
expect(await search.totalResults()).toBeGreaterThan(narrowed);
await expect(search.origin).toHaveValue('');
});
test('sorts by lowest fare', async ({ search, evidence }) => {
await search.searchRoute('JFK', 'LHR');
await search.sortBy('price');
const fares: number[] = [];
for (let i = 0; i < (await search.results.count()); i++) {
fares.push(await search.card(i).fare());
}
expect(fares.length).toBeGreaterThan(1);
expect([...fares]).toEqual([...fares].sort((a, b) => a - b));
await evidence.step('Results ordered by lowest fare', {
sortedBy: 'price',
faresOnPage: fares.map((f) => `$${f.toFixed(2)}`).join(', '),
lowest: `$${fares[0].toFixed(2)}`,
highest: `$${fares[fares.length - 1].toFixed(2)}`,
ascending: true,
});
});
test('sorts by shortest trip', async ({ search }) => {
await search.sortBy('duration');
const durations: number[] = [];
for (let i = 0; i < Math.min(5, await search.results.count()); i++) {
durations.push(await search.card(i).durationMinutes());
}
expect([...durations]).toEqual([...durations].sort((a, b) => a - b));
});
test('filters by cabin', async ({ search }) => {
await search.cabin.selectOption('business');
await search.searchButton.click();
await search.settled();
const count = await search.results.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(search.results.nth(i).locator('.fare-tags .badge').first()).toHaveText(
/business/i,
);
}
});
test('hides flights without room for the whole party', async ({ search }) => {
await search.setTravellers(9);
const count = await search.results.count();
for (let i = 0; i < count; i++) {
expect(await search.card(i).seatsLeft()).toBeGreaterThanOrEqual(9);
}
});
test('pages through results without repeating them', async ({ search }) => {
await expect(search.prevPage).toBeDisabled();
const firstPage = await search.results.first().innerText();
await search.nextPage.click();
await search.settled();
await expect(search.prevPage).toBeEnabled();
await expect(search.resultSummary).toContainText('Showing 11');
expect(await search.results.first().innerText()).not.toBe(firstPage);
await search.prevPage.click();
await search.settled();
await expect(search.resultSummary).toContainText('Showing 1–10');
});
test('a result card carries everything needed to choose', async ({ search }) => {
const card = search.card(0);
await expect(card.root.locator('.carrier-name')).not.toBeEmpty();
await expect(card.root.locator('.leg-duration')).toContainText(/\d+h|\d+m/);
await expect(card.price).toContainText('$');
await expect(card.seatsBadge).toContainText('left');
await expect(card.root.locator('.departure-date')).not.toBeEmpty();
await expect(card.selectButton).toBeEnabled();
});
});
+341
View File
@@ -0,0 +1,341 @@
/**
* Simulated-condition UI tests.
*
* Everything here answers the question "what does the screen do when the world
* misbehaves?" -- a failing API, a slow one, an absent one, an empty agency, a
* seat sold out from under the form, a session that expired mid-session.
*
* The backend is intercepted rather than used, which makes these tests fast,
* deterministic, and free of database side effects. They are the complement to
* the real-stack specs, not a replacement: those prove the system works, these
* prove the UI degrades well.
*/
import { expect, test } from '../fixtures/test';
import {
SESSION,
fail,
makeBooking,
makeDashboard,
makeFlight,
page$,
primeCredentials,
simulateApi,
slow,
} from '../fixtures/api-mock';
test.beforeEach(async ({ page }) => {
await primeCredentials(page);
});
// --- failing backend -------------------------------------------------------
test.describe('When the API fails', () => {
test('search shows an error instead of an empty page', async ({ page, search }) => {
await simulateApi(page, { flights: fail(500, 'boom') });
await page.goto('/search');
await expect(page.locator('.alert-error')).toContainText('Could not load flights');
// The filter panel stays usable so the agent can retry.
await expect(search.searchButton).toBeEnabled();
});
test('the dashboard explains itself rather than showing zeroes', async ({ page }) => {
await simulateApi(page, { dashboard: fail(503, 'unavailable') });
await page.goto('/dashboard');
await expect(page.locator('.alert-error')).toContainText('Could not load dashboard');
// Crucially, it does not render "$0 revenue" as though that were the truth.
await expect(page.locator('.tile-value')).toHaveCount(0);
});
test('the bookings list reports a failure', async ({ page }) => {
await simulateApi(page, { bookings: fail(500) });
await page.goto('/bookings');
await expect(page.locator('.alert-error')).toContainText('Could not load bookings');
});
test('a missing backend is named on the sign-in screen', async ({ page, login }) => {
// Status 0 is what the browser reports when nothing answers at all.
await simulateApi(page, { offline: true });
await page.goto('/login');
await expect(login.error).toContainText('Go backend');
});
test('going offline mid-session surfaces a failure, not a hang', async ({ page, search }) => {
await simulateApi(page);
await page.goto('/search');
await expect(search.results.first()).toBeVisible();
// The network drops, then the agent searches again.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await simulateApi(page, { offline: true });
await search.searchButton.click();
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 15_000 });
});
});
// --- slow backend ----------------------------------------------------------
test.describe('When the API is slow', () => {
test('search shows skeletons while it waits', async ({ page }) => {
await simulateApi(page, { flights: slow(page$([makeFlight()]), 2000) });
await page.goto('/search');
// The loading state must appear rather than a blank panel.
await expect(page.locator('.skeleton-card').first()).toBeVisible();
await expect(page.locator('.results-bar')).toContainText('Searching');
await expect(page.locator('.flight').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.skeleton-card')).toHaveCount(0);
});
test('the sign-in button reports that it is working', async ({ page, login }) => {
await simulateApi(page, { session: slow(SESSION, 1500) });
await page.context().clearCookies();
await page.addInitScript(() => localStorage.clear());
await page.goto('/login');
await login.useDemoChip({
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'agent@skyline.test',
password: 'demo1234',
fullName: 'Marcus Reid',
role: 'agent',
});
await login.submit.click();
await expect(login.submit).toContainText('Signing in');
await expect(login.submit).toBeDisabled();
});
test('the dashboard shows skeleton tiles before its figures', async ({ page }) => {
await simulateApi(page, { dashboard: slow(makeDashboard(), 1500) });
await page.goto('/dashboard');
await expect(page.locator('.skeleton').first()).toBeVisible();
await expect(page.locator('.tile-value').first()).toBeVisible({ timeout: 15_000 });
});
});
// --- expired session -------------------------------------------------------
test.describe('When the session expires', () => {
test('a 401 mid-session returns the agent to sign-in', async ({ page }) => {
await simulateApi(page);
await page.goto('/dashboard');
await expect(page.locator('.tile-value').first()).toBeVisible();
// The account is disabled elsewhere; the next call is rejected.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await simulateApi(page, {
bookings: fail(401, 'invalid credentials for this tenant'),
session: fail(401, 'invalid credentials for this tenant'),
});
await page.locator('.nav-link[href="/bookings"]').click();
await expect(page).toHaveURL(/\/login/);
// Stale credentials must not be left lying around.
expect(await page.evaluate(() => localStorage.getItem('travelhub.credentials'))).toBeNull();
});
});
// --- inventory and sales edge cases ----------------------------------------
test.describe('Edge cases in the data', () => {
test('an agency with no inventory sees an empty state, not an error', async ({ page }) => {
await simulateApi(page, { flights: page$([], 0) });
await page.goto('/search');
await expect(page.locator('.empty')).toContainText('Nothing on this route yet');
await expect(page.locator('.alert-error')).toHaveCount(0);
});
test('a brand new agency sees zeroes without a broken dashboard', async ({ page }) => {
await simulateApi(page, {
dashboard: makeDashboard({
totalBookings: 0,
confirmedBookings: 0,
cancelledBookings: 0,
totalRevenue: 0,
seatsSold: 0,
recentBookings: [],
// Go marshals an empty slice as null, which is the shape that breaks
// a naive .length in a template.
topRoutes: null,
}),
});
await page.goto('/dashboard');
await expect(page.locator('.tile-value').first()).toBeVisible();
await expect(page.getByText('No bookings yet')).toBeVisible();
await expect(page.getByText('Nothing sold yet')).toBeVisible();
});
test('the last seat is flagged as urgent', async ({ page, search }) => {
await simulateApi(page, {
flights: page$([
makeFlight({ id: 'f-scarce', flightNumber: 'AA1', seatsAvailable: 1 }),
makeFlight({ id: 'f-roomy', flightNumber: 'AA2', seatsAvailable: 120 }),
]),
});
await page.goto('/search');
await expect(search.results.nth(0).locator('.badge-warn')).toContainText('1 left');
await expect(search.results.nth(1).locator('.badge-warn')).toHaveCount(0);
});
test('a seat sold during checkout is explained clearly', async ({ page, checkout, search }) => {
await simulateApi(page, {
flights: page$([makeFlight({ seatsAvailable: 2 })]),
flightById: makeFlight({ seatsAvailable: 2 }),
// The seat goes while the form is open.
createBooking: fail(409, 'not enough seats available on this flight'),
});
await page.goto('/search');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTraveller(0, { firstName: 'Dana', lastName: 'Whitfield' });
await checkout.confirm.click();
await expect(checkout.error).toContainText('just sold');
// The agent keeps their typing, so they can pick another flight and retry.
await expect(page.locator('#first-0')).toHaveValue('Dana');
});
test('a party larger than the cabin is blocked before submitting', async ({
page,
checkout,
search,
}) => {
await simulateApi(page, {
flights: page$([makeFlight({ seatsAvailable: 1 })]),
flightById: makeFlight({ seatsAvailable: 1 }),
});
await page.goto('/search');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.addTraveller.click();
await expect(page.locator('.alert-error')).toContainText('Only 1 seat');
await expect(checkout.confirm).toBeDisabled();
});
test('a huge result set still paginates sensibly', async ({ page, search }) => {
const many = Array.from({ length: 10 }, (_, i) =>
makeFlight({ id: `f-${i}`, flightNumber: `AA${100 + i}`, basePrice: 100 + i }),
);
await simulateApi(page, { flights: page$(many, 98_432) });
await page.goto('/search');
await expect(search.resultSummary).toContainText('98432');
await expect(search.results).toHaveCount(10);
await expect(search.nextPage).toBeEnabled();
await expect(search.prevPage).toBeDisabled();
});
});
// --- hostile content -------------------------------------------------------
test.describe('Awkward content', () => {
test('very long names do not break the layout or escape the page', async ({ page }) => {
const longName = 'Wolfeschlegelsteinhausenbergerdorff'.repeat(4);
await simulateApi(page, {
bookingById: makeBooking({
passengers: [{ id: 'p-1', firstName: longName, lastName: longName }],
contactEmail: `${'a'.repeat(120)}@example.com`,
}),
});
await page.goto('/bookings/b-1');
await expect(page.locator('table.data tbody tr').first()).toBeVisible();
const overflows = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth + 1,
);
expect(overflows, 'a long passenger name must not widen the page').toBe(false);
});
test('markup in data is rendered as text, never as HTML', async ({ page }) => {
// Angular escapes interpolated values; this asserts nobody has reached for
// innerHTML since.
await simulateApi(page, {
bookingById: makeBooking({
passengers: [
{ id: 'p-1', firstName: '<img src=x onerror=alert(1)>', lastName: '<b>bold</b>' },
],
}),
});
await page.goto('/bookings/b-1');
const row = page.locator('table.data tbody tr').first();
await expect(row).toContainText('<b>bold</b>');
await expect(row.locator('b')).toHaveCount(0);
await expect(row.locator('img')).toHaveCount(0);
});
});
// --- keyboard only ---------------------------------------------------------
test.describe('Keyboard operation', () => {
test('the sign-in form can be completed without a mouse', async ({ page, login }) => {
await simulateApi(page);
await page.addInitScript(() => localStorage.clear());
await page.goto('/login');
await login.tenantSelect.focus();
await page.keyboard.press('Tab');
await expect(login.email).toBeFocused();
await page.keyboard.type('agent@skyline.test');
await page.keyboard.press('Tab');
await expect(login.password).toBeFocused();
await page.keyboard.type('demo1234');
await page.keyboard.press('Tab');
await expect(login.submit).toBeFocused();
});
test('every interactive control on search is reachable by tab', async ({ page }) => {
await simulateApi(page);
await page.goto('/search');
await expect(page.locator('.flight').first()).toBeVisible();
const reached = new Set<string>();
for (let i = 0; i < 25; i++) {
await page.keyboard.press('Tab');
const id = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
return el ? (el.id || el.className || el.tagName) : '';
});
reached.add(id);
}
// The filter panel and the first result's action are all tab-reachable.
expect([...reached].join(' ')).toContain('origin');
expect([...reached].join(' ')).toContain('destination');
expect([...reached].join(' ')).toContain('btn');
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* The point of the product: two agencies share one deployment and one database,
* and neither can see the other's inventory, customers or revenue.
*
* These assertions are made through the UI, the way a customer would notice a
* leak, rather than against the API.
*/
import { expect, test, uniqueSurname } from '../fixtures/test';
import { GLOBETROTTER_AGENT, SKYLINE_AGENT } from '../fixtures/accounts';
test.describe('Tenant isolation', () => {
test('the same route is priced per agency', async ({
signInAs,
shell,
search,
login,
evidence,
}) => {
await signInAs(SKYLINE_AGENT);
await shell.goToSearch();
await search.searchRoute('JFK', 'LHR');
await search.sortBy('price');
const skylineFare = await search.card(0).fare();
await evidence.step('Skyline Travel: cheapest JFK to LHR', {
agency: 'Skyline Travel',
route: 'JFK to LHR',
cheapestFare: `$${skylineFare.toFixed(2)}`,
resultCount: await search.totalResults(),
});
await shell.signOutAndWait();
await login.signIn(GLOBETROTTER_AGENT);
await shell.goToSearch();
await search.searchRoute('JFK', 'LHR');
await search.sortBy('price');
const globetrotterFare = await search.card(0).fare();
await evidence.step('Globetrotter Tours: same route, its own fare', {
agency: 'Globetrotter Tours',
route: 'JFK to LHR',
cheapestFare: `$${globetrotterFare.toFixed(2)}`,
resultCount: await search.totalResults(),
});
await evidence.note('Per-tenant pricing confirmed', {
skyline: `$${skylineFare.toFixed(2)}`,
globetrotter: `$${globetrotterFare.toFixed(2)}`,
difference: `$${(globetrotterFare - skylineFare).toFixed(2)}`,
markup: `${(((globetrotterFare - skylineFare) / skylineFare) * 100).toFixed(1)}%`,
});
expect(skylineFare).toBeGreaterThan(0);
expect(globetrotterFare).toBeGreaterThan(0);
// Globetrotter sells the same seats at a negotiated markup.
expect(globetrotterFare).not.toBeCloseTo(skylineFare, 2);
expect(globetrotterFare).toBeGreaterThan(skylineFare);
});
test('the sidebar always names the agency you are working in', async ({
signInAs,
shell,
login,
}) => {
await signInAs(SKYLINE_AGENT);
await expect(shell.agencyName).toHaveText('Skyline Travel');
await expect(shell.agencySlug).toHaveText('skyline');
await shell.signOutAndWait();
await login.signIn(GLOBETROTTER_AGENT);
await expect(shell.agencyName).toHaveText('Globetrotter Tours');
await expect(shell.agencySlug).toHaveText('globetrotter');
});
test('one agency never sees another agency booking', async ({
signInAs,
login,
shell,
search,
checkout,
bookings,
bookingDetail,
page,
evidence,
}) => {
const surname = uniqueSurname('ISO');
// --- Skyline sells a ticket --------------------------------------------
await signInAs(SKYLINE_AGENT);
await shell.goToSearch();
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTravellers([{ firstName: 'Iris', lastName: surname }]);
await checkout.confirmBooking();
const reference = await bookingDetail.bookingReference();
const bookingUrl = page.url();
await evidence.step('Skyline sells a ticket', {
agency: 'Skyline Travel',
bookingReference: reference,
passenger: `Iris ${surname}`,
bookingUrl,
});
await shell.signOutAndWait();
// --- Globetrotter must not find it -------------------------------------
await login.signIn(GLOBETROTTER_AGENT);
await shell.goToBookings();
await expect(bookings.rowFor(reference)).toHaveCount(0);
expect(await page.content()).not.toContain(surname);
await evidence.step('Globetrotter cannot see it in their bookings', {
signedInAs: 'Globetrotter Tours',
searchedFor: reference,
rowsFound: 0,
passengerNameOnPage: false,
});
// Not even by deep-linking straight at the booking's own URL.
await page.goto(bookingUrl);
await expect(page.getByText('Booking not found')).toBeVisible();
expect(await page.content()).not.toContain(surname);
await evidence.step('Deep link to the booking URL is refused', {
signedInAs: 'Globetrotter Tours',
requestedUrl: bookingUrl,
result: 'Booking not found',
dataLeaked: false,
});
// --- clean up as the owning agency -------------------------------------
await shell.signOutAndWait();
await login.signIn(SKYLINE_AGENT);
await shell.goToBookings();
await bookings.open(reference);
await bookingDetail.cancel(reference);
});
test('revenue figures are per agency', async ({ signInAs, login, shell, dashboard }) => {
await signInAs(SKYLINE_AGENT);
await dashboard.settled();
const skylineRevenue = await dashboard.tileValue('Revenue');
const skylineBookings = await dashboard.tileValue('Bookings');
await shell.signOutAndWait();
await login.signIn(GLOBETROTTER_AGENT);
await dashboard.settled();
const globetrotterRevenue = await dashboard.tileValue('Revenue');
const globetrotterBookings = await dashboard.tileValue('Bookings');
expect(skylineRevenue).toBeGreaterThan(0);
expect(globetrotterRevenue).toBeGreaterThan(0);
// Separate books: the seeded agencies sold different volumes.
expect(skylineRevenue).not.toBe(globetrotterRevenue);
expect(skylineBookings).not.toBe(globetrotterBookings);
});
test('a deep link to another agency flight is refused', async ({
signInAs,
login,
shell,
search,
page,
}) => {
// Capture a flight id that belongs to Globetrotter.
await signInAs(GLOBETROTTER_AGENT);
await shell.goToSearch();
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
const globetrotterBookUrl = page.url();
await page.goto('/dashboard');
await shell.signOutAndWait();
// Skyline holds a valid id, and still cannot reach it.
await login.signIn(SKYLINE_AGENT);
await page.goto(globetrotterBookUrl);
await expect(page.getByText('Flight not available')).toBeVisible();
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.8.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kB",
"maximumError": "16kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+8069
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.17.0",
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+16
View File
@@ -0,0 +1,16 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { authInterceptor } from './core/auth.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// withComponentInputBinding lets route params arrive as component inputs,
// which is how the booking and confirmation screens receive their ids.
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
+49
View File
@@ -0,0 +1,49 @@
import { Routes } from '@angular/router';
import { authGuard, guestGuard } from './core/auth.guard';
// Every feature is lazily loaded, so the sign-in screen ships on its own and
// the console bundle only arrives once someone is actually signed in.
export const routes: Routes = [
{
path: 'login',
canActivate: [guestGuard],
title: 'Sign in | TravelHub',
loadComponent: () => import('./features/login/login').then((m) => m.LoginPage),
},
{
path: '',
canActivate: [authGuard],
loadComponent: () => import('./layout/shell').then((m) => m.Shell),
children: [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{
path: 'dashboard',
title: 'Dashboard | TravelHub',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.DashboardPage),
},
{
path: 'search',
title: 'Search flights | TravelHub',
loadComponent: () => import('./features/search/search').then((m) => m.SearchPage),
},
{
path: 'book/:flightId',
title: 'Complete booking | TravelHub',
loadComponent: () => import('./features/checkout/checkout').then((m) => m.CheckoutPage),
},
{
path: 'bookings',
title: 'Bookings | TravelHub',
loadComponent: () => import('./features/bookings/bookings').then((m) => m.BookingsPage),
},
{
path: 'bookings/:bookingId',
title: 'Booking detail | TravelHub',
loadComponent: () =>
import('./features/bookings/booking-detail').then((m) => m.BookingDetailPage),
},
],
},
{ path: '**', redirectTo: '' },
];
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
/** Root component: routing decides between the sign-in screen and the shell. */
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
+125
View File
@@ -0,0 +1,125 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApiService } from './api.service';
describe('ApiService', () => {
let api: ApiService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(ApiService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
describe('searchFlights', () => {
it('sends only the filters that were actually set', () => {
api.searchFlights({ origin: 'JFK', destination: 'LHR', limit: 10 }).subscribe();
const req = http.expectOne((r) => r.url === '/api/flights');
expect(req.request.params.get('origin')).toBe('JFK');
expect(req.request.params.get('destination')).toBe('LHR');
expect(req.request.params.get('limit')).toBe('10');
expect(req.request.params.has('date')).toBe(false);
expect(req.request.params.has('cabin')).toBe(false);
req.flush({ items: [], total: 0, limit: 10, offset: 0 });
});
it('drops empty strings so a cleared filter does not narrow the search', () => {
api.searchFlights({ origin: '', destination: '', cabin: '', date: '' }).subscribe();
const req = http.expectOne((r) => r.url === '/api/flights');
expect(req.request.params.keys()).toEqual([]);
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
});
it('keeps a zero offset, which is a real value rather than an absent one', () => {
api.searchFlights({ offset: 0, passengers: 2 }).subscribe();
const req = http.expectOne((r) => r.url === '/api/flights');
expect(req.request.params.get('offset')).toBe('0');
expect(req.request.params.get('passengers')).toBe('2');
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
});
});
it('requests one flight by id', () => {
api.getFlight('flight-1').subscribe();
const req = http.expectOne('/api/flights/flight-1');
expect(req.request.method).toBe('GET');
req.flush({});
});
describe('listBookings', () => {
it('defaults to the first page of every status', () => {
api.listBookings().subscribe();
const req = http.expectOne((r) => r.url === '/api/bookings');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('offset')).toBe('0');
expect(req.request.params.has('status')).toBe(false);
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
});
it('passes a status filter through when given', () => {
api.listBookings('confirmed', 10, 20).subscribe();
const req = http.expectOne((r) => r.url === '/api/bookings');
expect(req.request.params.get('status')).toBe('confirmed');
expect(req.request.params.get('limit')).toBe('10');
expect(req.request.params.get('offset')).toBe('20');
req.flush({ items: [], total: 0, limit: 10, offset: 20 });
});
});
it('posts a booking', () => {
const body = {
flightId: 'f-1',
contactEmail: 'ops@example.com',
contactPhone: '',
passengers: [{ firstName: 'Ada', lastName: 'Lovelace', dateOfBirth: '', passportNumber: '' }],
};
api.createBooking(body).subscribe();
const req = http.expectOne('/api/bookings');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(body);
// The client never names a tenant; the server derives it from credentials.
expect(JSON.stringify(req.request.body)).not.toContain('tenant');
req.flush({});
});
it('cancels a booking with an empty body', () => {
api.cancelBooking('b-1').subscribe();
const req = http.expectOne('/api/bookings/b-1/cancel');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({});
req.flush({});
});
it('reads the dashboard', () => {
api.dashboard().subscribe();
const req = http.expectOne('/api/dashboard');
expect(req.request.method).toBe('GET');
req.flush({});
});
it('reads the tenant reference lists', () => {
api.listAirports().subscribe();
http.expectOne('/api/airports').flush([]);
api.listAirlines().subscribe();
http.expectOne('/api/airlines').flush([]);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
Airline,
Airport,
Booking,
CreateBookingRequest,
DashboardStats,
Flight,
FlightQuery,
Page,
} from './models';
/**
* Every call here is implicitly tenant-scoped: the interceptor attaches the
* tenant slug, and the server derives the tenant id from the credentials. The
* client never sends a tenant id, and could not usefully forge one.
*/
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient);
searchFlights(query: FlightQuery): Observable<Page<Flight>> {
let params = new HttpParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<Page<Flight>>('/api/flights', { params });
}
getFlight(id: string): Observable<Flight> {
return this.http.get<Flight>(`/api/flights/${id}`);
}
listAirports(): Observable<Airport[]> {
return this.http.get<Airport[]>('/api/airports');
}
listAirlines(): Observable<Airline[]> {
return this.http.get<Airline[]>('/api/airlines');
}
listBookings(status = '', limit = 25, offset = 0): Observable<Page<Booking>> {
let params = new HttpParams().set('limit', limit).set('offset', offset);
if (status) {
params = params.set('status', status);
}
return this.http.get<Page<Booking>>('/api/bookings', { params });
}
getBooking(id: string): Observable<Booking> {
return this.http.get<Booking>(`/api/bookings/${id}`);
}
createBooking(request: CreateBookingRequest): Observable<Booking> {
return this.http.post<Booking>('/api/bookings', request);
}
cancelBooking(id: string): Observable<Booking> {
return this.http.post<Booking>(`/api/bookings/${id}/cancel`, {});
}
dashboard(): Observable<DashboardStats> {
return this.http.get<DashboardStats>('/api/dashboard');
}
}
+74
View File
@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, RouterStateSnapshot, UrlTree } from '@angular/router';
import { authGuard, guestGuard } from './auth.guard';
import { AuthService } from './auth.service';
/** Minimal stand-in: the guards only ever call restore(). */
class FakeAuth {
constructor(private readonly restorable: boolean) {}
restore = () => Promise.resolve(this.restorable);
}
function setup(signedIn: boolean) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: new FakeAuth(signedIn) }],
});
}
function stateFor(url: string): RouterStateSnapshot {
return { url } as RouterStateSnapshot;
}
const route = {} as ActivatedRouteSnapshot;
describe('authGuard', () => {
it('lets a signed-in user through', async () => {
setup(true);
const result = await TestBed.runInInjectionContext(() =>
authGuard(route, stateFor('/dashboard')),
);
expect(result).toBe(true);
});
it('redirects to sign-in and remembers where the user was heading', async () => {
setup(false);
const result = await TestBed.runInInjectionContext(() =>
authGuard(route, stateFor('/bookings/abc-123')),
);
expect(result).toBeInstanceOf(UrlTree);
expect((result as UrlTree).toString()).toBe('/login?returnTo=%2Fbookings%2Fabc-123');
});
it('omits returnTo when the destination was the root', async () => {
setup(false);
const result = await TestBed.runInInjectionContext(() => authGuard(route, stateFor('/')));
expect((result as UrlTree).toString()).toBe('/login');
});
});
describe('guestGuard', () => {
it('lets a signed-out visitor reach the sign-in screen', async () => {
setup(false);
const result = await TestBed.runInInjectionContext(() => guestGuard(route, stateFor('/login')));
expect(result).toBe(true);
});
it('sends an already signed-in user to the dashboard', async () => {
setup(true);
const result = await TestBed.runInInjectionContext(() => guestGuard(route, stateFor('/login')));
expect(result).toBeInstanceOf(UrlTree);
expect((result as UrlTree).toString()).toBe('/dashboard');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
/**
* Blocks the application shell until a session is confirmed.
*
* On a fresh page load there are credentials in storage but no session object
* yet, so the guard re-validates them against the server before letting the
* route through. That way a revoked account cannot linger on screen.
*/
export const authGuard: CanActivateFn = async (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (await auth.restore()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: state.url === '/' ? {} : { returnTo: state.url },
});
};
/** Sends an already-signed-in user away from the sign-in screen. */
export const guestGuard: CanActivateFn = async () => {
const auth = inject(AuthService);
const router = inject(Router);
if (await auth.restore()) {
return router.createUrlTree(['/dashboard']);
}
return true;
};
@@ -0,0 +1,149 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
const STORAGE_KEY = 'travelhub.credentials';
const BASIC = btoa('agent@skyline.test:demo1234');
describe('authInterceptor', () => {
let http: HttpClient;
let backend: HttpTestingController;
let auth: AuthService;
let navigated: string[][];
function create(withCredentials = true): void {
if (withCredentials) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tenant: 'skyline', basic: BASIC }));
}
navigated = [];
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{
provide: Router,
useValue: {
navigate: (commands: string[]) => {
navigated.push(commands);
return Promise.resolve(true);
},
},
},
],
});
http = TestBed.inject(HttpClient);
backend = TestBed.inject(HttpTestingController);
auth = TestBed.inject(AuthService);
}
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
afterEach(() => {
backend.verify();
localStorage.clear();
});
it('stamps API calls with the credentials and tenant', () => {
create();
http.get('/api/flights').subscribe();
const req = backend.expectOne('/api/flights');
expect(req.request.headers.get('Authorization')).toBe(`Basic ${BASIC}`);
expect(req.request.headers.get('X-Tenant')).toBe('skyline');
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
});
it('leaves non-API requests alone', () => {
create();
http.get('/assets/config.json').subscribe();
const req = backend.expectOne('/assets/config.json');
expect(req.request.headers.has('Authorization')).toBe(false);
expect(req.request.headers.has('X-Tenant')).toBe(false);
req.flush({});
});
it('does not overwrite credentials a caller set explicitly', () => {
create();
const probe = btoa('someone@else.test:trying');
http.get('/api/session', { headers: { Authorization: `Basic ${probe}` } }).subscribe();
// This is how signIn() tests candidate credentials before storing them.
const req = backend.expectOne('/api/session');
expect(req.request.headers.get('Authorization')).toBe(`Basic ${probe}`);
req.flush({});
});
it('adds nothing when signed out', () => {
create(false);
http.get('/api/tenants').subscribe();
const req = backend.expectOne('/api/tenants');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
it('signs out and redirects when stored credentials go stale', () => {
create();
expect(auth.hasCredentials()).toBe(true);
http.get('/api/bookings').subscribe({ error: () => undefined });
backend
.expectOne('/api/bookings')
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
expect(auth.hasCredentials()).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(navigated).toEqual([['/login']]);
});
it('does not sign out on a 401 from an unstamped request', () => {
create(false);
http
.get('/api/session', { headers: { Authorization: 'Basic bogus' } })
.subscribe({ error: () => undefined });
backend
.expectOne('/api/session')
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
// A failed sign-in attempt must not trigger a redirect loop.
expect(navigated).toEqual([]);
});
it('leaves the session intact for other error statuses', () => {
create();
http.get('/api/flights').subscribe({ error: () => undefined });
backend
.expectOne('/api/flights')
.flush({ error: 'boom' }, { status: 500, statusText: 'Server Error' });
expect(auth.hasCredentials()).toBe(true);
expect(navigated).toEqual([]);
});
it('passes the error through to the caller', async () => {
create();
const failure = new Promise<number>((resolve) => {
http.get('/api/flights').subscribe({ error: (err) => resolve(err.status) });
});
backend.expectOne('/api/flights').flush({}, { status: 500, statusText: 'Server Error' });
expect(await failure).toBe(500);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
/**
* Stamps every API call with the Basic credentials and the tenant slug.
*
* Requests that already carry an Authorization header are left alone: that is
* how signIn() probes a candidate set of credentials before storing them.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const credentials = auth.currentCredentials();
const shouldStamp =
credentials !== null && req.url.startsWith('/api/') && !req.headers.has('Authorization');
const outgoing = shouldStamp
? req.clone({
setHeaders: {
Authorization: `Basic ${credentials.basic}`,
'X-Tenant': credentials.tenant,
},
})
: req;
return next(outgoing).pipe(
catchError((error: unknown) => {
// A 401 on a stamped request means the stored credentials went stale --
// the account was removed, or its password changed elsewhere. Drop them
// and send the user back to sign in rather than looping on failures.
if (error instanceof HttpErrorResponse && error.status === 401 && shouldStamp) {
auth.signOut();
void router.navigate(['/login']);
}
return throwError(() => error);
}),
);
};
+205
View File
@@ -0,0 +1,205 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { Session } from './models';
const STORAGE_KEY = 'travelhub.credentials';
function sessionFor(slug: string, email: string): Session {
return {
user: {
id: 'u-1',
tenantId: 't-1',
email,
fullName: 'Marcus Reid',
role: 'agent',
createdAt: '2026-01-01T00:00:00Z',
},
tenant: {
id: 't-1',
slug,
name: 'Skyline Travel',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
},
};
}
describe('AuthService', () => {
let auth: AuthService;
let http: HttpTestingController;
/** Builds the service after any localStorage priming a test needs. */
function create(): void {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
auth = TestBed.inject(AuthService);
http = TestBed.inject(HttpTestingController);
}
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
afterEach(() => {
if (http) {
http.verify();
}
localStorage.clear();
});
it('starts signed out with nothing in storage', () => {
create();
expect(auth.session()).toBeNull();
expect(auth.hasCredentials()).toBe(false);
expect(auth.currentCredentials()).toBeNull();
});
it('fetches the public tenant list without credentials', () => {
create();
auth.listTenants().subscribe();
const req = http.expectOne('/api/tenants');
expect(req.request.method).toBe('GET');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([{ slug: 'skyline', name: 'Skyline Travel' }]);
});
it('verifies credentials against the chosen tenant before storing them', async () => {
create();
const pending = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
const req = http.expectOne('/api/session');
// The candidate credentials must ride on this very request -- the
// interceptor cannot stamp them, because they are not stored yet.
expect(req.request.headers.get('Authorization')).toBe(
'Basic ' + btoa('agent@skyline.test:demo1234'),
);
expect(req.request.headers.get('X-Tenant')).toBe('skyline');
req.flush(sessionFor('skyline', 'agent@skyline.test'));
await pending;
expect(auth.session()?.tenant.slug).toBe('skyline');
expect(auth.user()?.email).toBe('agent@skyline.test');
expect(auth.isAdmin()).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toContain('skyline');
});
it('leaves no trace when the server rejects the credentials', async () => {
create();
const pending = auth.signIn('skyline', 'agent@skyline.test', 'wrong');
http.expectOne('/api/session').flush(
{ error: 'invalid credentials for this tenant' },
{ status: 401, statusText: 'Unauthorized' },
);
await expect(pending).rejects.toBeTruthy();
expect(auth.session()).toBeNull();
expect(auth.hasCredentials()).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('reports the admin role', async () => {
create();
const pending = auth.signIn('skyline', 'admin@skyline.test', 'demo1234');
const session = sessionFor('skyline', 'admin@skyline.test');
session.user.role = 'admin';
http.expectOne('/api/session').flush(session);
await pending;
expect(auth.isAdmin()).toBe(true);
});
it('revalidates stored credentials on restore', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ tenant: 'skyline', basic: btoa('agent@skyline.test:demo1234') }),
);
create();
// Credentials exist, but the session is not trusted until the server says so.
expect(auth.hasCredentials()).toBe(true);
expect(auth.session()).toBeNull();
const pending = auth.restore();
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
expect(await pending).toBe(true);
expect(auth.session()).not.toBeNull();
});
it('discards stored credentials the server no longer accepts', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ tenant: 'skyline', basic: btoa('ghost@skyline.test:stale') }),
);
create();
const pending = auth.restore();
http
.expectOne('/api/session')
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
expect(await pending).toBe(false);
expect(auth.hasCredentials()).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('restores without a second round trip once the session is known', async () => {
create();
const signingIn = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
await signingIn;
expect(await auth.restore()).toBe(true);
http.expectNone('/api/session');
});
it('short-circuits restore when there are no credentials', async () => {
create();
expect(await auth.restore()).toBe(false);
http.expectNone('/api/session');
});
it('clears everything on sign out', async () => {
create();
const pending = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
await pending;
auth.signOut();
expect(auth.session()).toBeNull();
expect(auth.tenant()).toBeNull();
expect(auth.hasCredentials()).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('treats unreadable storage as signed out rather than crashing', () => {
localStorage.setItem(STORAGE_KEY, 'not-json-at-all');
create();
expect(auth.hasCredentials()).toBe(false);
});
it('ignores a stored value of the wrong shape', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tenant: 'skyline' }));
create();
expect(auth.hasCredentials()).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { PublicTenant, Session } from './models';
/** Shape persisted to localStorage so a reload keeps the user signed in. */
interface StoredCredentials {
tenant: string;
basic: string;
}
const STORAGE_KEY = 'travelhub.credentials';
/**
* Holds the Basic auth credentials and the resolved session.
*
* Basic auth means the credentials themselves must be replayed on every
* request, so they are kept in localStorage rather than exchanged for a token.
* That is a real trade-off of the scheme, not an oversight -- see the
* authentication section of the README for what to swap in for production.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly credentials = signal<StoredCredentials | null>(readStored());
private readonly sessionSignal = signal<Session | null>(null);
/** The signed-in user and tenant, or null when signed out. */
readonly session = this.sessionSignal.asReadonly();
readonly user = computed(() => this.sessionSignal()?.user ?? null);
readonly tenant = computed(() => this.sessionSignal()?.tenant ?? null);
readonly isAdmin = computed(() => this.sessionSignal()?.user.role === 'admin');
/** True while credentials exist, even before the session is confirmed. */
readonly hasCredentials = computed(() => this.credentials() !== null);
/** Read by the HTTP interceptor to stamp outgoing requests. */
currentCredentials(): StoredCredentials | null {
return this.credentials();
}
/** The agency list shown on the sign-in screen. Needs no credentials. */
listTenants() {
return this.http.get<PublicTenant[]>('/api/tenants');
}
/**
* Verifies a set of credentials by calling /api/session with them, and keeps
* them only if the server accepts. Nothing is written to storage until the
* server has confirmed the login, so a failed attempt leaves no trace.
*/
async signIn(tenant: string, email: string, password: string): Promise<Session> {
const candidate: StoredCredentials = {
tenant,
basic: btoa(`${email}:${password}`),
};
const session = await firstValueFrom(
this.http.get<Session>('/api/session', {
headers: {
Authorization: `Basic ${candidate.basic}`,
'X-Tenant': candidate.tenant,
},
}),
);
this.credentials.set(candidate);
this.sessionSignal.set(session);
localStorage.setItem(STORAGE_KEY, JSON.stringify(candidate));
return session;
}
/**
* Re-validates credentials found in storage on app start. Returns false if
* there are none or the server rejects them, in which case the guard sends
* the user to the sign-in screen.
*/
async restore(): Promise<boolean> {
if (this.sessionSignal() !== null) {
return true;
}
if (this.credentials() === null) {
return false;
}
try {
const session = await firstValueFrom(this.http.get<Session>('/api/session'));
this.sessionSignal.set(session);
return true;
} catch {
this.signOut();
return false;
}
}
signOut(): void {
this.credentials.set(null);
this.sessionSignal.set(null);
localStorage.removeItem(STORAGE_KEY);
}
}
function readStored(): StoredCredentials | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<StoredCredentials>;
if (typeof parsed.tenant === 'string' && typeof parsed.basic === 'string') {
return { tenant: parsed.tenant, basic: parsed.basic };
}
return null;
} catch {
// Private-mode browsers and cleared site data both land here; signed out
// is the correct fallback.
return null;
}
}
+141
View File
@@ -0,0 +1,141 @@
// TypeScript mirrors of the Go API types in backend/internal/models.
// Field names match the JSON tags exactly.
export interface Tenant {
id: string;
slug: string;
name: string;
status: string;
createdAt: string;
}
/** What GET /api/tenants returns: enough to populate the sign-in picker. */
export interface PublicTenant {
slug: string;
name: string;
}
export interface User {
id: string;
tenantId: string;
email: string;
fullName: string;
role: 'admin' | 'agent';
createdAt: string;
}
export interface Session {
user: User;
tenant: Tenant;
}
export interface Airport {
id: string;
iataCode: string;
name: string;
city: string;
country: string;
timezone: string;
}
export interface Airline {
id: string;
iataCode: string;
name: string;
logoUrl?: string;
}
export type CabinClass = 'economy' | 'premium_economy' | 'business' | 'first';
export interface Flight {
id: string;
flightNumber: string;
airline: Airline;
origin: Airport;
destination: Airport;
departureTime: string;
arrivalTime: string;
durationMinutes: number;
aircraft: string;
cabinClass: CabinClass;
basePrice: number;
currency: string;
seatsTotal: number;
seatsAvailable: number;
status: string;
}
export interface Passenger {
id: string;
firstName: string;
lastName: string;
dateOfBirth?: string;
passportNumber?: string;
seatNumber?: string;
}
export interface Booking {
id: string;
bookingReference: string;
flight: Flight;
passengers: Passenger[];
passengerCount: number;
totalAmount: number;
currency: string;
status: 'confirmed' | 'cancelled';
contactEmail: string;
contactPhone?: string;
bookedBy: string;
createdAt: string;
}
/** Envelope shared by every list endpoint. */
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface FlightQuery {
origin?: string;
destination?: string;
date?: string;
cabin?: CabinClass | '';
passengers?: number;
sort?: 'price' | 'duration' | 'departure';
limit?: number;
offset?: number;
}
export interface NewPassenger {
firstName: string;
lastName: string;
dateOfBirth: string;
passportNumber: string;
}
export interface CreateBookingRequest {
flightId: string;
contactEmail: string;
contactPhone: string;
passengers: NewPassenger[];
}
export interface TopRoute {
route: string;
bookings: number;
revenue: number;
}
export interface DashboardStats {
totalBookings: number;
confirmedBookings: number;
cancelledBookings: number;
totalRevenue: number;
currency: string;
flightsInStock: number;
seatsSold: number;
recentBookings: Booking[];
topRoutes: TopRoute[] | null;
}
@@ -0,0 +1,190 @@
@if (loading()) {
<div class="card card-pad"><div class="skeleton" style="height: 300px"></div></div>
} @else if (notFound() || booking() === null) {
<div class="card empty">
<h3>Booking not found</h3>
<p>It may belong to another agency, or have been removed.</p>
<p style="margin-top: 14px">
<a routerLink="/bookings" class="btn btn-primary btn-sm">Back to bookings</a>
</p>
</div>
} @else {
@let b = booking()!;
@if (justBooked() && b.status === 'confirmed') {
<div class="confirmed-banner" role="status">
<span class="tick" aria-hidden="true">✓</span>
<div>
<strong>Booking confirmed.</strong>
Reference <span class="pnr">{{ b.bookingReference }}</span> — a confirmation would be sent
to {{ b.contactEmail }}.
</div>
</div>
}
<div class="page-head">
<div>
<h1>Booking {{ b.bookingReference }}</h1>
<p class="subtitle">
Created {{ b.createdAt | date: 'd MMM y, HH:mm' }} by {{ b.bookedBy }}
</p>
</div>
<div class="head-actions">
<span class="badge" [class]="'badge-' + b.status">{{ b.status }}</span>
<a routerLink="/bookings" class="btn btn-ghost btn-sm">← All bookings</a>
</div>
</div>
@if (error()) {
<div class="alert alert-error" role="alert">{{ error() }}</div>
}
<div class="detail">
<div class="stack">
<!-- Itinerary -->
<section class="card">
<div class="card-head"><h2>Itinerary</h2></div>
<div class="card-pad">
<div class="trip">
<span class="carrier-code">{{ b.flight.airline.iataCode }}</span>
<div class="trip-body">
<div class="trip-head">
<span class="trip-airline">{{ b.flight.airline.name }}</span>
<span class="trip-meta">
{{ b.flight.flightNumber }} · {{ b.flight.aircraft }} ·
<span class="cap">{{ cabinLabel(b.flight.cabinClass) }}</span>
</span>
</div>
<div class="trip-route">
<div class="trip-point">
<span class="trip-time tabular">{{ b.flight.departureTime | date: 'HH:mm' }}</span>
<span class="trip-iata">{{ b.flight.origin.iataCode }}</span>
<span class="trip-place">
{{ b.flight.origin.city }} · {{ b.flight.origin.name }}
</span>
<span class="trip-date">{{ b.flight.departureTime | date: 'EEE d MMM y' }}</span>
</div>
<div class="trip-leg">
<span class="trip-duration">{{ b.flight.durationMinutes | duration }}</span>
<span class="trip-line" aria-hidden="true"></span>
</div>
<div class="trip-point">
<span class="trip-time tabular">{{ b.flight.arrivalTime | date: 'HH:mm' }}</span>
<span class="trip-iata">{{ b.flight.destination.iataCode }}</span>
<span class="trip-place">
{{ b.flight.destination.city }} · {{ b.flight.destination.name }}
</span>
<span class="trip-date">{{ b.flight.arrivalTime | date: 'EEE d MMM y' }}</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Travellers -->
<section class="card">
<div class="card-head">
<h2>Travellers</h2>
<span class="muted">{{ b.passengerCount }} on this booking</span>
</div>
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Name</th>
<th>Date of birth</th>
<th>Passport</th>
<th>Seat</th>
</tr>
</thead>
<tbody>
@for (p of b.passengers; track p.id) {
<tr>
<td><strong>{{ p.firstName }} {{ p.lastName }}</strong></td>
<td class="tabular">
{{ p.dateOfBirth ? (p.dateOfBirth | date: 'd MMM y') : '—' }}
</td>
<td>{{ p.passportNumber || '—' }}</td>
<td>{{ p.seatNumber || 'Not assigned' }}</td>
</tr>
}
</tbody>
</table>
</div>
</section>
</div>
<!-- Summary and actions -->
<aside class="stack">
<section class="card">
<div class="card-head"><h2>Summary</h2></div>
<div class="card-pad">
<dl class="sum-list">
<div><dt>Reference</dt><dd class="pnr">{{ b.bookingReference }}</dd></div>
<div><dt>Status</dt><dd class="cap">{{ b.status }}</dd></div>
<div><dt>Travellers</dt><dd>{{ b.passengerCount }}</dd></div>
<div>
<dt>Fare each</dt>
<dd class="tabular">
{{ b.flight.basePrice | currency: b.currency : 'symbol' : '1.2-2' }}
</dd>
</div>
<div><dt>Contact</dt><dd>{{ b.contactEmail }}</dd></div>
@if (b.contactPhone) {
<div><dt>Phone</dt><dd>{{ b.contactPhone }}</dd></div>
}
</dl>
</div>
<div class="card-pad total-row">
<span>Total paid</span>
<strong class="tabular">
{{ b.totalAmount | currency: b.currency : 'symbol' : '1.2-2' }}
</strong>
</div>
</section>
@if (b.status === 'confirmed') {
<section class="card card-pad">
<h3>Cancel this booking</h3>
<p class="muted cancel-copy">
The reservation is voided and {{ b.passengerCount }} seat(s) return to inventory. This
cannot be undone.
</p>
@if (confirming()) {
<div class="confirm-row">
<button
type="button"
class="btn btn-danger btn-sm"
[disabled]="cancelling()"
(click)="confirmCancel()"
>
{{ cancelling() ? 'Cancelling…' : 'Yes, cancel booking' }}
</button>
<button
type="button"
class="btn btn-ghost btn-sm"
[disabled]="cancelling()"
(click)="dismissCancel()"
>
Keep it
</button>
</div>
} @else {
<button type="button" class="btn btn-danger btn-sm" (click)="askCancel()">
Cancel booking
</button>
}
</section>
} @else {
<div class="alert alert-info">
This booking was cancelled and its seats returned to inventory.
</div>
}
</aside>
</div>
}
@@ -0,0 +1,247 @@
.confirmed-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
margin-bottom: 20px;
border-radius: var(--radius);
background: var(--teal-100);
border: 1px solid #a8e0d8;
color: #045c51;
font-size: 0.92rem;
}
.tick {
display: grid;
place-items: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
background: var(--teal-600);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.head-actions .badge {
text-transform: capitalize;
}
.detail {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
align-items: start;
}
/* --- itinerary ----------------------------------------------------------- */
.trip {
display: flex;
gap: 14px;
}
.carrier-code {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 10px;
background: var(--navy-900);
color: #fff;
font-weight: 700;
font-size: 0.88rem;
}
.trip-body {
flex: 1;
min-width: 0;
}
.trip-head {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.trip-airline {
font-weight: 650;
font-size: 1rem;
}
.trip-meta {
font-size: 0.81rem;
color: var(--text-muted);
}
.trip-route {
display: grid;
grid-template-columns: 1fr minmax(90px, 0.7fr) 1fr;
gap: 16px;
align-items: center;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.trip-point {
display: flex;
flex-direction: column;
min-width: 0;
}
.trip-time {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.15;
}
.trip-iata {
font-size: 0.88rem;
font-weight: 650;
color: var(--brand-600);
letter-spacing: 0.04em;
}
.trip-place {
font-size: 0.79rem;
color: var(--text-muted);
}
.trip-date {
font-size: 0.76rem;
color: var(--text-faint);
margin-top: 3px;
}
.trip-leg {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.trip-duration {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 550;
white-space: nowrap;
}
.trip-line {
width: 100%;
height: 1px;
background: var(--border-strong);
position: relative;
}
.trip-line::after {
content: '✈';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
color: var(--brand-500);
font-size: 0.76rem;
background: var(--surface);
padding-left: 3px;
}
/* --- summary ------------------------------------------------------------- */
.sum-list {
margin: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.sum-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.88rem;
}
.sum-list dt {
color: var(--text-muted);
flex-shrink: 0;
}
.sum-list dd {
margin: 0;
font-weight: 550;
text-align: right;
min-width: 0;
overflow-wrap: anywhere;
}
.pnr {
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
font-weight: 650;
letter-spacing: 0.06em;
}
.cap {
text-transform: capitalize;
}
.total-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--border);
background: var(--surface-sunken);
border-radius: 0 0 var(--radius) var(--radius);
font-size: 0.92rem;
}
.total-row strong {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.025em;
}
/* --- cancel -------------------------------------------------------------- */
.cancel-copy {
font-size: 0.85rem;
line-height: 1.5;
margin: 6px 0 14px;
}
.confirm-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* --- narrow screens ------------------------------------------------------ */
@media (max-width: 980px) {
/* minmax(0, 1fr) keeps the travellers table inside its scroll container
instead of stretching the column to its min-content width. */
.detail {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.trip-route {
grid-template-columns: 1fr;
gap: 14px;
}
.trip-line {
display: none;
}
}
@@ -0,0 +1,177 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { Observable, of, throwError } from 'rxjs';
import { BookingDetailPage } from './booking-detail';
import { ApiService } from '../../core/api.service';
import { Booking } from '../../core/models';
function booking(status: 'confirmed' | 'cancelled' = 'confirmed'): Booking {
return {
id: 'b-1',
bookingReference: 'S3GTBQ',
flight: {
id: 'f-1',
flightNumber: 'AA100',
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
origin: {
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
},
destination: {
id: 'a-2', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'UK', timezone: 'UTC',
},
departureTime: '2026-09-19T18:00:00Z',
arrivalTime: '2026-09-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 483.3,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 163,
status: 'scheduled',
},
passengers: [
{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield', passportNumber: 'X1234567' },
{ id: 'p-2', firstName: 'Elias', lastName: 'Whitfield' },
],
passengerCount: 2,
totalAmount: 966.6,
currency: 'USD',
status,
contactEmail: 'ops@example.com',
contactPhone: '+1-555-0101',
bookedBy: 'Marcus Reid',
createdAt: '2026-09-12T13:10:00Z',
};
}
class FakeApi {
detail: Observable<Booking> = of(booking());
cancelled: Observable<Booking> = of(booking('cancelled'));
cancelCalls: string[] = [];
getBooking = () => this.detail;
cancelBooking = (id: string) => {
this.cancelCalls.push(id);
return this.cancelled;
};
}
describe('BookingDetailPage', () => {
let fixture: ComponentFixture<BookingDetailPage>;
let page: BookingDetailPage;
let api: FakeApi;
function create(confirmedFlag = ''): void {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: ApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap(confirmedFlag ? { confirmed: confirmedFlag } : {}),
},
},
},
],
});
fixture = TestBed.createComponent(BookingDetailPage);
fixture.componentRef.setInput('bookingId', 'b-1');
page = fixture.componentInstance;
fixture.detectChanges();
}
beforeEach(() => {
api = new FakeApi();
});
it('loads the booking with its travellers', () => {
create();
expect(page.booking()?.bookingReference).toBe('S3GTBQ');
expect(fixture.nativeElement.querySelectorAll('table.data tbody tr').length).toBe(2);
expect(page.loading()).toBe(false);
});
it('shows the confirmation banner only when arriving from checkout', () => {
create('1');
expect(page.justBooked()).toBe(true);
expect(fixture.nativeElement.querySelector('.confirmed-banner')).toBeTruthy();
create();
expect(page.justBooked()).toBe(false);
expect(fixture.nativeElement.querySelector('.confirmed-banner')).toBeNull();
});
it('asks for confirmation before cancelling', () => {
create();
expect(page.confirming()).toBe(false);
expect(api.cancelCalls).toEqual([]);
page.askCancel();
expect(page.confirming()).toBe(true);
// Still nothing sent -- the destructive call waits for the second click.
expect(api.cancelCalls).toEqual([]);
});
it('lets the user back out', () => {
create();
page.askCancel();
page.dismissCancel();
expect(page.confirming()).toBe(false);
expect(api.cancelCalls).toEqual([]);
});
it('cancels on confirmation and reflects the new status', () => {
create('1');
page.askCancel();
page.confirmCancel();
fixture.detectChanges();
expect(api.cancelCalls).toEqual(['b-1']);
expect(page.booking()?.status).toBe('cancelled');
expect(page.confirming()).toBe(false);
expect(page.cancelling()).toBe(false);
// The success banner is stale once the booking is voided.
expect(page.justBooked()).toBe(false);
expect(fixture.nativeElement.querySelector('.alert-info')).toBeTruthy();
});
it('reports a cancel that failed and leaves the booking alone', () => {
api.cancelled = throwError(() => new HttpErrorResponse({ status: 500 }));
create();
page.askCancel();
page.confirmCancel();
expect(page.error()).toContain('could not be cancelled');
expect(page.booking()?.status).toBe('confirmed');
expect(page.cancelling()).toBe(false);
});
it('offers no cancel control on an already cancelled booking', () => {
api.detail = of(booking('cancelled'));
create();
expect(fixture.nativeElement.querySelector('.cancel-copy')).toBeNull();
expect(fixture.nativeElement.querySelector('.alert-info')).toBeTruthy();
});
it('shows a not-found state for a booking in another agency', () => {
api.detail = throwError(() => new HttpErrorResponse({ status: 404 }));
create();
expect(page.notFound()).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Booking not found');
});
});
@@ -0,0 +1,94 @@
import { Component, effect, inject, input, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { Booking } from '../../core/models';
import { DurationPipe } from '../../shared/duration.pipe';
/** One reservation in full, with the option to cancel it. */
@Component({
selector: 'app-booking-detail',
imports: [RouterLink, CurrencyPipe, DatePipe, DurationPipe],
templateUrl: './booking-detail.html',
styleUrl: './booking-detail.scss',
})
export class BookingDetailPage {
private readonly api = inject(ApiService);
private readonly route = inject(ActivatedRoute);
/** Bound from the :bookingId route parameter. */
readonly bookingId = input.required<string>();
readonly booking = signal<Booking | null>(null);
readonly loading = signal(true);
readonly notFound = signal(false);
readonly cancelling = signal(false);
readonly error = signal<string | null>(null);
readonly confirming = signal(false);
/** True right after checkout, so the page can open with a success banner. */
readonly justBooked = signal(false);
constructor() {
this.justBooked.set(this.route.snapshot.queryParamMap.get('confirmed') === '1');
effect(() => {
const id = this.bookingId();
this.load(id);
});
}
cabinLabel(cabin: string): string {
return cabin.replace(/_/g, ' ');
}
/** Two-step cancel: the button asks for confirmation before it fires. */
askCancel(): void {
this.confirming.set(true);
}
dismissCancel(): void {
this.confirming.set(false);
}
confirmCancel(): void {
const current = this.booking();
if (current === null || this.cancelling()) {
return;
}
this.cancelling.set(true);
this.error.set(null);
this.api.cancelBooking(current.id).subscribe({
next: (updated) => {
this.booking.set(updated);
this.cancelling.set(false);
this.confirming.set(false);
this.justBooked.set(false);
},
error: () => {
this.cancelling.set(false);
this.confirming.set(false);
this.error.set('The booking could not be cancelled. Please refresh and try again.');
},
});
}
private load(id: string): void {
this.loading.set(true);
this.notFound.set(false);
this.api.getBooking(id).subscribe({
next: (booking) => {
this.booking.set(booking);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.notFound.set(true);
},
});
}
}
@@ -0,0 +1,105 @@
<div class="page-head">
<div>
<h1>Bookings</h1>
<p class="subtitle">Every reservation sold by your agency.</p>
</div>
<a routerLink="/search" class="btn btn-primary btn-sm">+ New booking</a>
</div>
<div class="filters" role="group" aria-label="Filter by status">
<button
type="button"
class="filter"
[class.active]="status() === ''"
(click)="filterBy('')"
>
All
</button>
<button
type="button"
class="filter"
[class.active]="status() === 'confirmed'"
(click)="filterBy('confirmed')"
>
Confirmed
</button>
<button
type="button"
class="filter"
[class.active]="status() === 'cancelled'"
(click)="filterBy('cancelled')"
>
Cancelled
</button>
</div>
@if (error()) {
<div class="alert alert-error" role="alert">{{ error() }}</div>
}
@if (loading()) {
<div class="card card-pad"><div class="skeleton" style="height: 260px"></div></div>
} @else if (bookings().length === 0) {
<div class="card empty">
<h3>No bookings yet</h3>
<p>Search for a flight to create the first reservation.</p>
<p style="margin-top: 14px">
<a routerLink="/search" class="btn btn-primary btn-sm">Search flights</a>
</p>
</div>
} @else {
<div class="card table-wrap">
<table class="data">
<thead>
<tr>
<th>Reference</th>
<th>Route</th>
<th>Departure</th>
<th>Travellers</th>
<th>Total</th>
<th>Status</th>
<th>Booked by</th>
<th></th>
</tr>
</thead>
<tbody>
@for (booking of bookings(); track booking.id) {
<tr>
<td><span class="pnr">{{ booking.bookingReference }}</span></td>
<td>
<span class="route">
{{ booking.flight.origin.iataCode }} → {{ booking.flight.destination.iataCode }}
</span>
<span class="route-meta">
{{ booking.flight.airline.iataCode }} {{ booking.flight.flightNumber }}
</span>
</td>
<td class="tabular">{{ booking.flight.departureTime | date: 'd MMM y, HH:mm' }}</td>
<td class="tabular">{{ booking.passengerCount }}</td>
<td class="tabular">
{{ booking.totalAmount | currency: booking.currency : 'symbol' : '1.2-2' }}
</td>
<td>
<span class="badge" [class]="'badge-' + booking.status">{{ booking.status }}</span>
</td>
<td class="muted">{{ booking.bookedBy }}</td>
<td class="right">
<a [routerLink]="['/bookings', booking.id]" class="btn btn-ghost btn-sm">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (canPrev() || canNext()) {
<nav class="pager" aria-label="Pagination">
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canPrev()" (click)="prevPage()">
← Previous
</button>
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canNext()" (click)="nextPage()">
Next →
</button>
</nav>
}
}
@@ -0,0 +1,66 @@
.filters {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.filter {
padding: 7px 15px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--surface);
font: inherit;
font-size: 0.86rem;
font-weight: 550;
color: var(--text-muted);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.filter:hover {
border-color: var(--brand-500);
color: var(--brand-600);
}
.filter.active {
background: var(--navy-900);
border-color: var(--navy-900);
color: #fff;
}
.pnr {
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
font-weight: 650;
letter-spacing: 0.06em;
font-size: 0.9rem;
}
.route {
display: block;
font-weight: 600;
letter-spacing: 0.02em;
}
.route-meta {
display: block;
font-size: 0.76rem;
color: var(--text-muted);
}
.badge {
text-transform: capitalize;
}
.right {
text-align: right;
}
.pager {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 18px;
}
@@ -0,0 +1,143 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Observable, of, throwError } from 'rxjs';
import { BookingsPage } from './bookings';
import { ApiService } from '../../core/api.service';
import { Booking, Page } from '../../core/models';
function booking(ref: string, status: 'confirmed' | 'cancelled' = 'confirmed'): Booking {
return {
id: `b-${ref}`,
bookingReference: ref,
flight: {
id: 'f-1',
flightNumber: 'AA100',
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
origin: {
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
},
destination: {
id: 'a-2', iataCode: 'LHR', name: 'LHR', city: 'London', country: 'UK', timezone: 'UTC',
},
departureTime: '2026-09-19T18:00:00Z',
arrivalTime: '2026-09-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 483.3,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 165,
status: 'scheduled',
},
passengers: [],
passengerCount: 2,
totalAmount: 966.6,
currency: 'USD',
status,
contactEmail: 'ops@example.com',
bookedBy: 'Marcus Reid',
createdAt: '2026-09-12T13:10:00Z',
};
}
class FakeApi {
result: Observable<Page<Booking>> = of({
items: [booking('S3GTBQ')],
total: 1,
limit: 20,
offset: 0,
});
calls: Array<[string, number, number]> = [];
listBookings = (status: string, limit: number, offset: number) => {
this.calls.push([status, limit, offset]);
return this.result;
};
}
describe('BookingsPage', () => {
let fixture: ComponentFixture<BookingsPage>;
let page: BookingsPage;
let api: FakeApi;
function create(): void {
fixture = TestBed.createComponent(BookingsPage);
page = fixture.componentInstance;
fixture.detectChanges();
}
beforeEach(() => {
TestBed.resetTestingModule();
api = new FakeApi();
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: ApiService, useValue: api }],
});
});
it('loads every booking on open', () => {
create();
expect(api.calls).toEqual([['', 20, 0]]);
expect(page.bookings().length).toBe(1);
expect(page.loading()).toBe(false);
});
it('filters by status and starts again from page one', () => {
api.result = of({ items: [booking('A')], total: 60, limit: 20, offset: 0 });
create();
page.nextPage();
expect(page.offset()).toBe(20);
page.filterBy('cancelled');
expect(page.status()).toBe('cancelled');
expect(page.offset()).toBe(0);
expect(api.calls[api.calls.length - 1]).toEqual(['cancelled', 20, 0]);
});
it('pages through a long list', () => {
api.result = of({ items: [booking('A')], total: 45, limit: 20, offset: 0 });
create();
expect(page.canPrev()).toBe(false);
expect(page.canNext()).toBe(true);
page.nextPage();
page.nextPage();
expect(page.offset()).toBe(40);
expect(page.canNext()).toBe(false);
page.prevPage();
expect(page.offset()).toBe(20);
});
it('shows an empty state with a way forward', () => {
api.result = of({ items: [], total: 0, limit: 20, offset: 0 });
create();
const text = fixture.nativeElement.textContent;
expect(text).toContain('No bookings yet');
expect(fixture.nativeElement.querySelector('a[href="/search"]')).toBeTruthy();
});
it('surfaces a load failure', () => {
api.result = throwError(() => new Error('boom'));
create();
expect(page.error()).toContain('Could not load bookings');
expect(page.loading()).toBe(false);
});
it('renders a row with the reference, route and status', () => {
create();
const row = fixture.nativeElement.querySelector('table.data tbody tr');
expect(row.textContent).toContain('S3GTBQ');
expect(row.textContent).toContain('JFK');
expect(row.textContent).toContain('LHR');
expect(row.querySelector('.badge-confirmed')).toBeTruthy();
});
});
@@ -0,0 +1,70 @@
import { Component, computed, inject, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { Booking } from '../../core/models';
const PAGE_SIZE = 20;
/** All reservations sold by this agency. */
@Component({
selector: 'app-bookings',
imports: [RouterLink, CurrencyPipe, DatePipe],
templateUrl: './bookings.html',
styleUrl: './bookings.scss',
})
export class BookingsPage {
private readonly api = inject(ApiService);
readonly bookings = signal<Booking[]>([]);
readonly total = signal(0);
readonly offset = signal(0);
readonly status = signal<'' | 'confirmed' | 'cancelled'>('');
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly canPrev = computed(() => this.offset() > 0);
readonly canNext = computed(() => this.offset() + PAGE_SIZE < this.total());
constructor() {
this.load();
}
filterBy(status: '' | 'confirmed' | 'cancelled'): void {
this.status.set(status);
this.offset.set(0);
this.load();
}
prevPage(): void {
if (this.canPrev()) {
this.offset.update((o) => Math.max(0, o - PAGE_SIZE));
this.load();
}
}
nextPage(): void {
if (this.canNext()) {
this.offset.update((o) => o + PAGE_SIZE);
this.load();
}
}
private load(): void {
this.loading.set(true);
this.error.set(null);
this.api.listBookings(this.status(), PAGE_SIZE, this.offset()).subscribe({
next: (page) => {
this.bookings.set(page.items);
this.total.set(page.total);
this.loading.set(false);
},
error: () => {
this.error.set('Could not load bookings. Please try again.');
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,186 @@
@if (loading()) {
<div class="card card-pad"><div class="skeleton" style="height: 220px"></div></div>
} @else if (loadFailed() || flight() === null) {
<div class="card empty">
<h3>Flight not available</h3>
<p>It may have been withdrawn from your agency's inventory.</p>
<p style="margin-top: 14px">
<a routerLink="/search" class="btn btn-primary btn-sm">Back to search</a>
</p>
</div>
} @else {
@let f = flight()!;
<div class="page-head">
<div>
<h1>Complete booking</h1>
<p class="subtitle">Enter traveller details, then confirm.</p>
</div>
<a routerLink="/search" class="btn btn-ghost btn-sm">← Back to search</a>
</div>
@if (error()) {
<div class="alert alert-error" role="alert">{{ error() }}</div>
}
<div class="checkout">
<!-- Left: traveller and contact details -->
<form [formGroup]="form" (ngSubmit)="submit()" class="stack" novalidate>
<section class="card">
<div class="card-head">
<h2>Travellers</h2>
<button
type="button"
class="btn btn-ghost btn-sm"
(click)="addPassenger()"
[disabled]="passengers.length >= 9"
>
+ Add traveller
</button>
</div>
<div class="card-pad stack" formArrayName="passengers">
@for (group of passengers.controls; track $index) {
<fieldset class="pax" [formGroupName]="$index">
<legend>
Traveller {{ $index + 1 }}
@if (passengers.length > 1) {
<button type="button" class="link-remove" (click)="removePassenger($index)">
Remove
</button>
}
</legend>
<div class="pax-grid">
<div class="field">
<label [attr.for]="'first-' + $index">First name</label>
<input
[id]="'first-' + $index"
type="text"
formControlName="firstName"
autocomplete="given-name"
[class.invalid]="showError($index, 'firstName')"
/>
@if (showError($index, 'firstName')) {
<span class="error">Required.</span>
}
</div>
<div class="field">
<label [attr.for]="'last-' + $index">Last name</label>
<input
[id]="'last-' + $index"
type="text"
formControlName="lastName"
autocomplete="family-name"
[class.invalid]="showError($index, 'lastName')"
/>
@if (showError($index, 'lastName')) {
<span class="error">Required.</span>
}
</div>
<div class="field">
<label [attr.for]="'dob-' + $index">Date of birth</label>
<input [id]="'dob-' + $index" type="date" formControlName="dateOfBirth" />
<span class="hint">Optional</span>
</div>
<div class="field">
<label [attr.for]="'passport-' + $index">Passport number</label>
<input [id]="'passport-' + $index" type="text" formControlName="passportNumber" />
<span class="hint">Optional</span>
</div>
</div>
</fieldset>
}
</div>
</section>
<section class="card">
<div class="card-head"><h2>Contact details</h2></div>
<div class="card-pad">
<div class="pax-grid">
<div class="field">
<label for="contactEmail">Email</label>
<input id="contactEmail" type="email" formControlName="contactEmail" />
@if (form.controls.contactEmail.touched && form.controls.contactEmail.invalid) {
<span class="error">Enter a valid email address.</span>
}
</div>
<div class="field">
<label for="contactPhone">Phone</label>
<input id="contactPhone" type="tel" formControlName="contactPhone" />
<span class="hint">Optional</span>
</div>
</div>
</div>
</section>
<button
type="submit"
class="btn btn-primary btn-confirm"
[disabled]="submitting() || seatsShort()"
>
{{ submitting() ? 'Confirming…' : 'Confirm booking' }}
</button>
</form>
<!-- Right: itinerary and fare summary -->
<aside class="summary">
<div class="card">
<div class="card-head"><h2>Your flight</h2></div>
<div class="card-pad stack">
<div class="sum-carrier">
<span class="carrier-code">{{ f.airline.iataCode }}</span>
<span>
<span class="sum-airline">{{ f.airline.name }}</span>
<span class="sum-meta">{{ f.flightNumber }} · {{ f.aircraft }}</span>
</span>
</div>
<div class="sum-route">
<div class="sum-point">
<span class="sum-time tabular">{{ f.departureTime | date: 'HH:mm' }}</span>
<span class="sum-iata">{{ f.origin.iataCode }}</span>
<span class="sum-city">{{ f.origin.city }}</span>
</div>
<span class="sum-arrow" aria-hidden="true">→</span>
<div class="sum-point">
<span class="sum-time tabular">{{ f.arrivalTime | date: 'HH:mm' }}</span>
<span class="sum-iata">{{ f.destination.iataCode }}</span>
<span class="sum-city">{{ f.destination.city }}</span>
</div>
</div>
<dl class="sum-list">
<div><dt>Date</dt><dd>{{ f.departureTime | date: 'EEE d MMM y' }}</dd></div>
<div><dt>Duration</dt><dd>{{ f.durationMinutes | duration }}</dd></div>
<div><dt>Cabin</dt><dd class="cap">{{ cabinLabel(f.cabinClass) }}</dd></div>
<div><dt>Seats left</dt><dd>{{ f.seatsAvailable }}</dd></div>
</dl>
</div>
<div class="card-pad fare-total">
<div class="fare-row muted">
<span>
{{ f.basePrice | currency: f.currency : 'symbol' : '1.2-2' }} ×
{{ passengerCount() }}
{{ passengerCount() === 1 ? 'traveller' : 'travellers' }}
</span>
</div>
<div class="fare-row total">
<span>Total</span>
<strong class="tabular">{{ total() | currency: f.currency : 'symbol' : '1.2-2' }}</strong>
</div>
@if (seatsShort()) {
<div class="alert alert-error" style="margin-top: 12px">
Only {{ f.seatsAvailable }} seat(s) remain. Remove travellers to continue.
</div>
}
</div>
</div>
</aside>
</div>
}
@@ -0,0 +1,199 @@
.checkout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 20px;
align-items: start;
}
/* --- traveller fieldsets ------------------------------------------------- */
.pax {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px 18px;
margin: 0;
background: var(--surface-sunken);
}
.pax legend {
display: flex;
align-items: baseline;
gap: 12px;
padding: 0 8px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.link-remove {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 0.72rem;
text-transform: none;
letter-spacing: 0;
color: var(--rose-600);
cursor: pointer;
text-decoration: underline;
}
.pax-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
}
.btn-confirm {
align-self: flex-start;
padding: 12px 26px;
}
/* --- summary ------------------------------------------------------------- */
.summary {
position: sticky;
top: 20px;
}
.sum-carrier {
display: flex;
align-items: center;
gap: 12px;
}
.sum-carrier > span:last-child {
display: flex;
flex-direction: column;
min-width: 0;
}
.carrier-code {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: var(--navy-900);
color: #fff;
font-weight: 700;
font-size: 0.85rem;
}
.sum-airline {
font-weight: 600;
font-size: 0.92rem;
}
.sum-meta {
font-size: 0.78rem;
color: var(--text-muted);
}
.sum-route {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 10px;
align-items: center;
padding: 14px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.sum-point {
display: flex;
flex-direction: column;
text-align: center;
}
.sum-time {
font-size: 1.16rem;
font-weight: 650;
line-height: 1.2;
}
.sum-iata {
font-size: 0.8rem;
font-weight: 650;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.sum-city {
font-size: 0.74rem;
color: var(--text-faint);
}
.sum-arrow {
color: var(--brand-500);
font-size: 1rem;
}
.sum-list {
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.sum-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.88rem;
}
.sum-list dt {
color: var(--text-muted);
}
.sum-list dd {
margin: 0;
font-weight: 550;
}
.cap {
text-transform: capitalize;
}
.fare-total {
border-top: 1px solid var(--border);
background: var(--surface-sunken);
border-radius: 0 0 var(--radius) var(--radius);
}
.fare-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.88rem;
}
.fare-row.total {
margin-top: 8px;
padding-top: 10px;
border-top: 1px dashed var(--border-strong);
font-size: 1rem;
}
.fare-row.total strong {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.025em;
}
/* --- narrow screens ------------------------------------------------------ */
@media (max-width: 980px) {
.checkout {
grid-template-columns: minmax(0, 1fr);
}
.summary {
position: static;
order: -1;
}
}
@@ -0,0 +1,247 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { Observable, of, throwError } from 'rxjs';
import { CheckoutPage } from './checkout';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { Booking, CreateBookingRequest, Flight } from '../../core/models';
function flight(overrides: Partial<Flight> = {}): Flight {
return {
id: 'f-1',
flightNumber: 'AA100',
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
origin: {
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
},
destination: {
id: 'a-2', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'UK', timezone: 'UTC',
},
departureTime: '2026-09-19T18:00:00Z',
arrivalTime: '2026-09-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 200,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 165,
status: 'scheduled',
...overrides,
};
}
class FakeApi {
flight: Observable<Flight> = of(flight());
created: Observable<Booking> = of({ id: 'b-1' } as Booking);
sent: CreateBookingRequest[] = [];
getFlight = () => this.flight;
createBooking = (req: CreateBookingRequest) => {
this.sent.push(req);
return this.created;
};
}
describe('CheckoutPage', () => {
let fixture: ComponentFixture<CheckoutPage>;
let page: CheckoutPage;
let api: FakeApi;
let navigations: Array<{ commands: unknown[]; extras?: unknown }>;
/** Builds the component with the given ?passengers= carried from search. */
function create(requestedPassengers = '1'): void {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: ApiService, useValue: api },
{
provide: AuthService,
useValue: { user: () => ({ email: 'agent@skyline.test' }) },
},
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap({ passengers: requestedPassengers }) },
},
},
],
});
const router = TestBed.inject(Router);
router.navigate = ((commands: unknown[], extras?: unknown) => {
navigations.push({ commands, extras });
return Promise.resolve(true);
}) as Router['navigate'];
fixture = TestBed.createComponent(CheckoutPage);
fixture.componentRef.setInput('flightId', 'f-1');
page = fixture.componentInstance;
fixture.detectChanges();
}
beforeEach(() => {
api = new FakeApi();
navigations = [];
});
it('loads the flight and prefills the agent as the contact', () => {
create();
expect(page.flight()?.flightNumber).toBe('AA100');
expect(page.loading()).toBe(false);
expect(page.form.controls.contactEmail.value).toBe('agent@skyline.test');
});
it('opens with one traveller form by default', () => {
create();
expect(page.passengers.length).toBe(1);
expect(page.passengerCount()).toBe(1);
expect(page.total()).toBe(200);
});
it('honours the traveller count carried over from search', () => {
create('3');
expect(page.passengers.length).toBe(3);
expect(page.total()).toBe(600);
});
it('clamps a silly traveller count to what the cabin allows', () => {
create('99');
expect(page.passengers.length).toBe(9);
});
it('never asks for more travellers than there are seats', () => {
api.flight = of(flight({ seatsAvailable: 2 }));
create('6');
expect(page.passengers.length).toBe(2);
});
it('ignores a non-numeric traveller count', () => {
create('lots');
expect(page.passengers.length).toBe(1);
});
it('adds and removes travellers, repricing as it goes', () => {
create();
page.addPassenger();
expect(page.passengerCount()).toBe(2);
expect(page.total()).toBe(400);
page.addPassenger();
expect(page.total()).toBe(600);
page.removePassenger(0);
expect(page.passengerCount()).toBe(2);
expect(page.total()).toBe(400);
});
it('will not go below one traveller or above nine', () => {
create();
page.removePassenger(0);
expect(page.passengers.length).toBe(1);
for (let i = 0; i < 20; i++) {
page.addPassenger();
}
expect(page.passengers.length).toBe(9);
});
it('warns when the party outgrows the remaining seats', () => {
api.flight = of(flight({ seatsAvailable: 2 }));
create('2');
expect(page.seatsShort()).toBe(false);
page.addPassenger();
expect(page.seatsShort()).toBe(true);
});
it('blocks submission until every required field is filled', async () => {
create();
await page.submit();
expect(api.sent).toEqual([]);
expect(page.error()).toContain('complete every required field');
expect(page.showError(0, 'firstName')).toBe(true);
});
it('submits the booking and moves to the confirmation', async () => {
create();
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
await page.submit();
expect(api.sent.length).toBe(1);
expect(api.sent[0].flightId).toBe('f-1');
expect(api.sent[0].passengers).toEqual([
{ firstName: 'Dana', lastName: 'Whitfield', dateOfBirth: '', passportNumber: '' },
]);
expect(navigations).toEqual([
{ commands: ['/bookings', 'b-1'], extras: { queryParams: { confirmed: 1 } } },
]);
});
it('explains a seat sold out from under the form', async () => {
api.created = throwError(() => new HttpErrorResponse({ status: 409 }));
create();
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
await page.submit();
expect(page.error()).toContain('just sold');
expect(page.submitting()).toBe(false);
expect(navigations).toEqual([]);
});
it('explains a flight that disappeared', async () => {
api.created = throwError(() => new HttpErrorResponse({ status: 404 }));
create();
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
await page.submit();
expect(page.error()).toContain('no longer available');
});
it('relays the server`s own validation message', async () => {
api.created = throwError(
() =>
new HttpErrorResponse({
status: 400,
error: { error: 'contactEmail is not a valid email address' },
}),
);
create();
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
await page.submit();
expect(page.error()).toBe('contactEmail is not a valid email address');
});
it('shows a not-found state when the flight cannot be loaded', () => {
api.flight = throwError(() => new HttpErrorResponse({ status: 404 }));
create();
expect(page.loadFailed()).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Flight not available');
});
it('renders cabin codes as words', () => {
create();
expect(page.cabinLabel('premium_economy')).toBe('premium economy');
});
});
@@ -0,0 +1,197 @@
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { Flight, NewPassenger } from '../../core/models';
import { DurationPipe } from '../../shared/duration.pipe';
/**
* Passenger details and confirmation for one flight.
*
* The fare summary re-reads seat availability from the flight record, and the
* server re-checks it under a row lock at submit time, so a seat sold while
* this form was open surfaces as a clear 409 rather than an oversell.
*/
@Component({
selector: 'app-checkout',
imports: [ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe, DurationPipe],
templateUrl: './checkout.html',
styleUrl: './checkout.scss',
})
export class CheckoutPage {
private readonly api = inject(ApiService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
/** Bound from the :flightId route parameter. */
readonly flightId = input.required<string>();
readonly flight = signal<Flight | null>(null);
readonly loading = signal(true);
readonly submitting = signal(false);
readonly error = signal<string | null>(null);
readonly loadFailed = signal(false);
readonly form = this.fb.nonNullable.group({
contactEmail: ['', [Validators.required, Validators.email]],
contactPhone: [''],
passengers: this.fb.array([this.passengerGroup()]),
});
readonly passengerCount = signal(1);
readonly total = computed(() => {
const flight = this.flight();
return flight === null ? 0 : flight.basePrice * this.passengerCount();
});
readonly seatsShort = computed(() => {
const flight = this.flight();
return flight !== null && flight.seatsAvailable < this.passengerCount();
});
constructor() {
// Prefill the contact address with the signed-in agent's, which is what
// they want for the majority of walk-up bookings.
const email = this.auth.user()?.email;
if (email) {
this.form.controls.contactEmail.setValue(email);
}
effect(() => {
const id = this.flightId();
this.load(id);
});
}
get passengers(): FormArray {
return this.form.controls.passengers;
}
addPassenger(): void {
if (this.passengers.length < 9) {
this.passengers.push(this.passengerGroup());
this.passengerCount.set(this.passengers.length);
}
}
removePassenger(index: number): void {
if (this.passengers.length > 1) {
this.passengers.removeAt(index);
this.passengerCount.set(this.passengers.length);
}
}
cabinLabel(cabin: string): string {
return cabin.replace(/_/g, ' ');
}
/** True once a passenger field has been touched and is failing validation. */
showError(index: number, field: string): boolean {
const control = this.passengers.at(index).get(field);
return control !== null && control.touched && control.invalid;
}
async submit(): Promise<void> {
const flight = this.flight();
if (flight === null || this.submitting()) {
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
this.error.set('Please complete every required field before confirming.');
return;
}
this.submitting.set(true);
this.error.set(null);
const raw = this.form.getRawValue();
const passengers = raw.passengers as NewPassenger[];
this.api
.createBooking({
flightId: flight.id,
contactEmail: raw.contactEmail,
contactPhone: raw.contactPhone,
passengers,
})
.subscribe({
next: (booking) => {
this.submitting.set(false);
void this.router.navigate(['/bookings', booking.id], {
queryParams: { confirmed: 1 },
});
},
error: (err: unknown) => {
this.submitting.set(false);
this.error.set(describe(err));
},
});
}
private load(id: string): void {
this.loading.set(true);
this.loadFailed.set(false);
this.api.getFlight(id).subscribe({
next: (flight) => {
this.flight.set(flight);
this.loading.set(false);
this.syncPassengersToQuery(flight);
},
error: () => {
this.loading.set(false);
this.loadFailed.set(true);
},
});
}
/** Honours the traveller count carried over from the search screen. */
private syncPassengersToQuery(flight: Flight): void {
const requested = Number(this.route.snapshot.queryParamMap.get('passengers') ?? '1');
const wanted = Math.min(
Math.max(Number.isFinite(requested) ? requested : 1, 1),
Math.min(9, Math.max(flight.seatsAvailable, 1)),
);
while (this.passengers.length < wanted) {
this.passengers.push(this.passengerGroup());
}
while (this.passengers.length > wanted) {
this.passengers.removeAt(this.passengers.length - 1);
}
this.passengerCount.set(this.passengers.length);
}
private passengerGroup() {
return this.fb.nonNullable.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
dateOfBirth: [''],
passportNumber: [''],
});
}
}
function describe(err: unknown): string {
if (err instanceof HttpErrorResponse) {
if (err.status === 409) {
return 'Those seats were just sold. Reduce the traveller count or pick another flight.';
}
if (err.status === 404) {
return 'This flight is no longer available.';
}
if (err.status === 400) {
const body = err.error as { error?: string } | null;
return body?.error ?? 'Some details were rejected. Please check the form.';
}
}
return 'The booking could not be completed. Please try again.';
}
@@ -0,0 +1,137 @@
<div class="page-head">
<div>
<h1>Good day, {{ userFirstName() }}</h1>
<p class="subtitle">Trading overview for {{ tenantName() }}.</p>
</div>
<a routerLink="/search" class="btn btn-primary btn-sm">+ New booking</a>
</div>
@if (error()) {
<div class="alert alert-error" role="alert">{{ error() }}</div>
}
@if (loading()) {
<div class="tiles">
@for (n of [1, 2, 3, 4]; track n) {
<div class="card card-pad"><div class="skeleton" style="height: 62px"></div></div>
}
</div>
} @else if (stats(); as s) {
<!-- Headline figures -->
<div class="tiles">
<article class="card card-pad tile">
<span class="tile-label">Revenue</span>
<span class="tile-value tabular">
{{ s.totalRevenue | currency: s.currency : 'symbol' : '1.0-0' }}
</span>
<span class="tile-meta">from {{ s.confirmedBookings }} confirmed bookings</span>
</article>
<article class="card card-pad tile">
<span class="tile-label">Bookings</span>
<span class="tile-value tabular">{{ s.totalBookings }}</span>
<span class="tile-meta">
<span class="badge badge-confirmed">{{ s.confirmedBookings }} confirmed</span>
@if (s.cancelledBookings > 0) {
<span class="badge badge-cancelled">{{ s.cancelledBookings }} cancelled</span>
}
</span>
</article>
<article class="card card-pad tile">
<span class="tile-label">Seats sold</span>
<span class="tile-value tabular">{{ s.seatsSold }}</span>
<span class="tile-meta">across all confirmed reservations</span>
</article>
<article class="card card-pad tile">
<span class="tile-label">Flights in stock</span>
<span class="tile-value tabular">{{ s.flightsInStock }}</span>
<span class="tile-meta">future departures in your inventory</span>
</article>
</div>
<div class="panels">
<!-- Recent bookings -->
<section class="card">
<div class="card-head">
<h2>Recent bookings</h2>
<a routerLink="/bookings">View all</a>
</div>
@if (s.recentBookings.length === 0) {
<div class="empty">
<h3>No bookings yet</h3>
<p>Reservations you sell will appear here.</p>
</div>
} @else {
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Reference</th>
<th>Route</th>
<th>Travellers</th>
<th>Total</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@for (booking of s.recentBookings; track booking.id) {
<tr [routerLink]="['/bookings', booking.id]" class="row-link">
<td><span class="pnr">{{ booking.bookingReference }}</span></td>
<td>
{{ booking.flight.origin.iataCode }} →
{{ booking.flight.destination.iataCode }}
</td>
<td class="tabular">{{ booking.passengerCount }}</td>
<td class="tabular">
{{ booking.totalAmount | currency: booking.currency : 'symbol' : '1.2-2' }}
</td>
<td>
<span class="badge" [class]="'badge-' + booking.status">
{{ booking.status }}
</span>
</td>
<td class="muted">{{ booking.createdAt | date: 'd MMM, HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<!-- Top routes -->
<section class="card">
<div class="card-head"><h2>Top routes</h2></div>
@if (topRoutes().length === 0) {
<div class="empty">
<h3>Nothing sold yet</h3>
<p>Your best-selling routes will rank here.</p>
</div>
} @else {
<div class="card-pad routes">
@for (route of topRoutes(); track route.route) {
<div class="route-row">
<div class="route-head">
<span class="route-name">{{ route.route }}</span>
<span class="route-revenue tabular">
{{ route.revenue | currency: s.currency : 'symbol' : '1.0-0' }}
</span>
</div>
<div class="route-bar" aria-hidden="true">
<span [style.width]="barWidth(route)"></span>
</div>
<span class="route-meta">
{{ route.bookings }} {{ route.bookings === 1 ? 'booking' : 'bookings' }}
</span>
</div>
}
</div>
}
</section>
</div>
}
@@ -0,0 +1,122 @@
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.tile {
display: flex;
flex-direction: column;
gap: 3px;
}
.tile-label {
font-size: 0.74rem;
font-weight: 650;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
}
.tile-value {
font-size: 1.95rem;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.15;
}
.tile-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
font-size: 0.79rem;
color: var(--text-muted);
margin-top: 3px;
}
/* --- panels -------------------------------------------------------------- */
.panels {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.pnr {
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
font-weight: 650;
letter-spacing: 0.06em;
font-size: 0.88rem;
}
.badge {
text-transform: capitalize;
}
.row-link {
cursor: pointer;
}
/* --- top routes ---------------------------------------------------------- */
.routes {
display: flex;
flex-direction: column;
gap: 17px;
}
.route-row {
display: flex;
flex-direction: column;
gap: 5px;
}
.route-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.route-name {
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.01em;
}
.route-revenue {
font-weight: 650;
font-size: 0.9rem;
}
.route-bar {
height: 7px;
border-radius: 999px;
background: var(--surface-sunken);
overflow: hidden;
}
.route-bar span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand-500), var(--teal-600));
}
.route-meta {
font-size: 0.77rem;
color: var(--text-muted);
}
/* --- narrow screens ------------------------------------------------------ */
@media (max-width: 1000px) {
/* minmax(0, 1fr) rather than 1fr: a bare 1fr track refuses to shrink below
its content's min-content width, which lets the bookings table push the
whole card past the viewport instead of scrolling inside .table-wrap. */
.panels {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,132 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Observable, of, throwError } from 'rxjs';
import { DashboardPage } from './dashboard';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { DashboardStats, Tenant, User } from '../../core/models';
const tenant: Tenant = {
id: 't-1',
slug: 'skyline',
name: 'Skyline Travel',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
};
const user: User = {
id: 'u-1',
tenantId: 't-1',
email: 'agent@skyline.test',
fullName: 'Marcus Reid',
role: 'agent',
createdAt: '2026-01-01T00:00:00Z',
};
function stats(overrides: Partial<DashboardStats> = {}): DashboardStats {
return {
totalBookings: 6,
confirmedBookings: 5,
cancelledBookings: 1,
totalRevenue: 7263.16,
currency: 'USD',
flightsInStock: 294,
seatsSold: 12,
recentBookings: [],
topRoutes: [
{ route: 'JFK to LHR', bookings: 3, revenue: 4000 },
{ route: 'LAX to DXB', bookings: 1, revenue: 1000 },
],
...overrides,
};
}
class FakeApi {
result: Observable<DashboardStats> = of(stats());
dashboard = () => this.result;
}
describe('DashboardPage', () => {
let fixture: ComponentFixture<DashboardPage>;
let page: DashboardPage;
let api: FakeApi;
function create(): void {
fixture = TestBed.createComponent(DashboardPage);
page = fixture.componentInstance;
fixture.detectChanges();
}
beforeEach(() => {
TestBed.resetTestingModule();
api = new FakeApi();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: ApiService, useValue: api },
{ provide: AuthService, useValue: { tenant: () => tenant, user: () => user } },
],
});
});
it('greets the user by first name and names the agency', () => {
create();
expect(page.userFirstName()).toBe('Marcus');
expect(page.tenantName()).toBe('Skyline Travel');
expect(fixture.nativeElement.textContent).toContain('Good day, Marcus');
expect(fixture.nativeElement.textContent).toContain('Skyline Travel');
});
it('renders the headline figures', () => {
create();
const text = fixture.nativeElement.textContent;
expect(text).toContain('294');
expect(text).toContain('12');
expect(page.loading()).toBe(false);
});
it('scales the route bars against the biggest earner', () => {
create();
expect(page.maxRouteRevenue()).toBe(4000);
expect(page.barWidth({ route: 'JFK to LHR', bookings: 3, revenue: 4000 })).toBe('100%');
expect(page.barWidth({ route: 'LAX to DXB', bookings: 1, revenue: 1000 })).toBe('25%');
});
it('keeps a tiny route visible rather than drawing nothing', () => {
create();
// 1 of 4000 rounds to 0%, which would render an invisible bar.
expect(page.barWidth({ route: 'TINY', bookings: 1, revenue: 1 })).toBe('4%');
});
it('copes with no sales at all', () => {
api.result = of(stats({ topRoutes: [], totalRevenue: 0, recentBookings: [] }));
create();
expect(page.maxRouteRevenue()).toBe(0);
expect(page.barWidth({ route: 'NONE', bookings: 0, revenue: 0 })).toBe('0%');
expect(fixture.nativeElement.textContent).toContain('Nothing sold yet');
});
// Go marshals an empty slice as null, which would break a bare .length.
it('treats a null topRoutes as an empty list', () => {
api.result = of(stats({ topRoutes: null }));
create();
expect(page.topRoutes()).toEqual([]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('surfaces a failure to load', () => {
api.result = throwError(() => new Error('boom'));
create();
expect(page.error()).toContain('Could not load dashboard');
expect(page.loading()).toBe(false);
});
});
@@ -0,0 +1,56 @@
import { Component, computed, inject, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { DashboardStats, TopRoute } from '../../core/models';
/** Agency overview: trading totals, best routes, latest reservations. */
@Component({
selector: 'app-dashboard',
imports: [RouterLink, CurrencyPipe, DatePipe],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class DashboardPage {
private readonly api = inject(ApiService);
private readonly auth = inject(AuthService);
readonly stats = signal<DashboardStats | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly tenantName = computed(() => this.auth.tenant()?.name ?? '');
readonly userFirstName = computed(() => this.auth.user()?.fullName.split(' ')[0] ?? '');
/** topRoutes is null rather than [] when Go marshals an unset slice. */
readonly topRoutes = computed<TopRoute[]>(() => this.stats()?.topRoutes ?? []);
/** Widest revenue figure, used to scale the bars in the routes panel. */
readonly maxRouteRevenue = computed(() =>
this.topRoutes().reduce((max, route) => Math.max(max, route.revenue), 0),
);
constructor() {
this.api.dashboard().subscribe({
next: (stats) => {
this.stats.set(stats);
this.loading.set(false);
},
error: () => {
this.error.set('Could not load dashboard figures. Please try again.');
this.loading.set(false);
},
});
}
barWidth(route: TopRoute): string {
const max = this.maxRouteRevenue();
if (max <= 0) {
return '0%';
}
// Floor at 4% so a tiny-but-real route is still visible as a bar.
return `${Math.max(4, Math.round((route.revenue / max) * 100))}%`;
}
}
+113
View File
@@ -0,0 +1,113 @@
<div class="auth">
<!-- Left: brand panel. Purely decorative, hidden on narrow screens. -->
<section class="hero" aria-hidden="true">
<div class="hero-inner">
<div class="brand">
<span class="brand-mark">✈</span>
<span class="brand-name">TravelHub</span>
</div>
<h1>One platform.<br />Every agency.</h1>
<p>
Search live fares, hold seats and manage reservations — with each agency's inventory,
customers and revenue kept strictly its own.
</p>
<ul class="hero-points">
<li>Per-tenant fares and availability</li>
<li>Booking and cancellation with instant seat accounting</li>
<li>Revenue and route reporting per agency</li>
</ul>
</div>
</section>
<!-- Right: the actual sign-in form. -->
<section class="panel">
<div class="panel-inner">
<header class="panel-head">
<h2>Sign in</h2>
<p class="muted">Choose your agency, then enter your credentials.</p>
</header>
@if (error()) {
<div class="alert alert-error" role="alert">{{ error() }}</div>
}
<form [formGroup]="form" (ngSubmit)="submit()" class="stack" novalidate>
<div class="field">
<label for="tenant">Agency</label>
<select id="tenant" formControlName="tenant">
<option value="" disabled>Select an agency…</option>
@for (tenant of tenants(); track tenant.slug) {
<option [value]="tenant.slug">{{ tenant.name }}</option>
}
</select>
@if (form.controls.tenant.touched && form.controls.tenant.invalid) {
<span class="error">Please choose an agency.</span>
}
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" autocomplete="username" />
@if (form.controls.email.touched && form.controls.email.invalid) {
<span class="error">Enter a valid email address.</span>
}
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (form.controls.password.touched && form.controls.password.invalid) {
<span class="error">Enter your password.</span>
}
</div>
<button type="submit" class="btn btn-primary btn-block" [disabled]="submitting()">
{{ submitting() ? 'Signing in…' : 'Sign in' }}
</button>
</form>
<div class="demo">
<h3>Demo accounts</h3>
<p class="muted">
Password <code>demo1234</code>. Sign in to each agency in turn and search JFK → LHR: the
same routes are priced differently per tenant.
</p>
<div class="demo-grid">
<button type="button" class="demo-chip" (click)="useDemo('skyline', 'admin@skyline.test')">
<span class="demo-agency">Skyline Travel</span>
<span class="demo-email">admin&#64;skyline.test</span>
<span class="badge badge-brand">admin</span>
</button>
<button type="button" class="demo-chip" (click)="useDemo('skyline', 'agent@skyline.test')">
<span class="demo-agency">Skyline Travel</span>
<span class="demo-email">agent&#64;skyline.test</span>
<span class="badge badge-neutral">agent</span>
</button>
<button
type="button"
class="demo-chip"
(click)="useDemo('globetrotter', 'admin@globetrotter.test')"
>
<span class="demo-agency">Globetrotter Tours</span>
<span class="demo-email">admin&#64;globetrotter.test</span>
<span class="badge badge-brand">admin</span>
</button>
<button
type="button"
class="demo-chip"
(click)="useDemo('globetrotter', 'agent@globetrotter.test')"
>
<span class="demo-agency">Globetrotter Tours</span>
<span class="demo-email">agent&#64;globetrotter.test</span>
<span class="badge badge-neutral">agent</span>
</button>
</div>
</div>
</div>
</section>
</div>

Some files were not shown because too many files have changed in this diff Show More