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:
+36
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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, ¤cy, &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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 $$;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
Generated
+93
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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-]+/);
|
||||
}
|
||||
}
|
||||
@@ -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, ''));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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(``, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 · end-to-end suite<br />${escapeHtml(stamp)}</p>
|
||||
|
||||
<div class="verdict ${allPassed ? 'ok' : 'bad'}">
|
||||
<span class="mark">${allPassed ? '✓' : '!'}</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 "Save as PDF"">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’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"> · 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;
|
||||
}
|
||||
});`;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+8069
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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])),
|
||||
],
|
||||
};
|
||||
@@ -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: '' },
|
||||
];
|
||||
@@ -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 {}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))}%`;
|
||||
}
|
||||
}
|
||||
@@ -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@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@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@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@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
Reference in New Issue
Block a user