From 4d3eb18a6cb58227a3c7ff452d607da5b815c121 Mon Sep 17 00:00:00 2001 From: "nilesh.p" Date: Sun, 13 Sep 2026 11:38:01 +0530 Subject: [PATCH] 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 --- .gitignore | 36 + README.md | 231 + TEST-CATALOG.md | 498 + TESTING.md | 391 + backend/.env.example | 19 + backend/cmd/api/main.go | 80 + backend/coverage | 512 ++ backend/go.mod | 17 + backend/go.sum | 30 + backend/internal/auth/auth.go | 47 + backend/internal/config/config.go | 136 + backend/internal/config/config_test.go | 217 + backend/internal/database/database.go | 40 + backend/internal/handlers/bookings.go | 162 + backend/internal/handlers/flights.go | 118 + backend/internal/handlers/handlers.go | 96 + backend/internal/handlers/handlers_test.go | 511 ++ backend/internal/handlers/session.go | 44 + backend/internal/httpapi/router.go | 55 + backend/internal/middleware/middleware.go | 223 + .../internal/middleware/middleware_test.go | 342 + backend/internal/models/models.go | 143 + backend/internal/repository/booking_repo.go | 330 + .../internal/repository/booking_repo_test.go | 415 + backend/internal/repository/flight_repo.go | 149 + .../internal/repository/flight_repo_test.go | 280 + backend/internal/repository/identity_repo.go | 72 + .../internal/repository/identity_repo_test.go | 117 + backend/internal/repository/reference_repo.go | 135 + backend/internal/repository/repository.go | 39 + backend/internal/testsupport/testsupport.go | 329 + backend/migrations/001_init.sql | 107 + backend/migrations/002_seed.sql | 121 + backend/migrations/003_demo_bookings.sql | 122 + e2e/fixtures/accounts.ts | 48 + e2e/fixtures/api-mock.ts | 265 + e2e/fixtures/evidence.ts | 83 + e2e/fixtures/test.ts | 88 + e2e/package-lock.json | 93 + e2e/package.json | 19 + e2e/pages/bookings.page.ts | 96 + e2e/pages/checkout.page.ts | 76 + e2e/pages/dashboard.page.ts | 33 + e2e/pages/login.page.ts | 56 + e2e/pages/search.page.ts | 119 + e2e/pages/shell.page.ts | 43 + e2e/playwright.config.ts | 76 + e2e/reporters/evidence.ts | 281 + e2e/reporters/render-html.ts | 483 + e2e/reporters/report-model.ts | 200 + e2e/scripts/report-pdf.mjs | 126 + e2e/tests/auth.spec.ts | 155 + e2e/tests/booking.spec.ts | 262 + e2e/tests/dashboard.spec.ts | 130 + e2e/tests/responsive.spec.ts | 66 + e2e/tests/search.spec.ts | 153 + e2e/tests/simulation.spec.ts | 341 + e2e/tests/tenant-isolation.spec.ts | 187 + e2e/tsconfig.json | 17 + frontend/.editorconfig | 17 + frontend/.gitignore | 44 + frontend/.prettierrc | 12 + frontend/.vscode/extensions.json | 4 + frontend/.vscode/launch.json | 20 + frontend/.vscode/tasks.json | 42 + frontend/README.md | 59 + frontend/angular.json | 82 + frontend/package-lock.json | 8069 +++++++++++++++++ frontend/package.json | 32 + frontend/proxy.conf.json | 8 + frontend/public/favicon.ico | Bin 0 -> 15086 bytes frontend/src/app/app.config.ts | 16 + frontend/src/app/app.routes.ts | 49 + frontend/src/app/app.ts | 10 + frontend/src/app/core/api.service.spec.ts | 125 + frontend/src/app/core/api.service.ts | 70 + frontend/src/app/core/auth.guard.spec.ts | 74 + frontend/src/app/core/auth.guard.ts | 35 + .../src/app/core/auth.interceptor.spec.ts | 149 + frontend/src/app/core/auth.interceptor.ts | 43 + frontend/src/app/core/auth.service.spec.ts | 205 + frontend/src/app/core/auth.service.ts | 121 + frontend/src/app/core/models.ts | 141 + .../app/features/bookings/booking-detail.html | 190 + .../app/features/bookings/booking-detail.scss | 247 + .../features/bookings/booking-detail.spec.ts | 177 + .../app/features/bookings/booking-detail.ts | 94 + .../src/app/features/bookings/bookings.html | 105 + .../src/app/features/bookings/bookings.scss | 66 + .../app/features/bookings/bookings.spec.ts | 143 + .../src/app/features/bookings/bookings.ts | 70 + .../src/app/features/checkout/checkout.html | 186 + .../src/app/features/checkout/checkout.scss | 199 + .../app/features/checkout/checkout.spec.ts | 247 + .../src/app/features/checkout/checkout.ts | 197 + .../src/app/features/dashboard/dashboard.html | 137 + .../src/app/features/dashboard/dashboard.scss | 122 + .../app/features/dashboard/dashboard.spec.ts | 132 + .../src/app/features/dashboard/dashboard.ts | 56 + frontend/src/app/features/login/login.html | 113 + frontend/src/app/features/login/login.scss | 237 + frontend/src/app/features/login/login.spec.ts | 196 + frontend/src/app/features/login/login.ts | 92 + frontend/src/app/features/search/search.html | 168 + frontend/src/app/features/search/search.scss | 350 + .../src/app/features/search/search.spec.ts | 264 + frontend/src/app/features/search/search.ts | 139 + frontend/src/app/layout/shell.html | 49 + frontend/src/app/layout/shell.scss | 225 + frontend/src/app/layout/shell.spec.ts | 114 + frontend/src/app/layout/shell.ts | 42 + frontend/src/app/shared/duration.pipe.spec.ts | 26 + frontend/src/app/shared/duration.pipe.ts | 17 + frontend/src/index.html | 20 + frontend/src/main.ts | 6 + frontend/src/styles.scss | 400 + frontend/tsconfig.app.json | 14 + frontend/tsconfig.json | 31 + frontend/tsconfig.spec.json | 14 + instruction.txt | 0 memory.md | 0 package.json | 43 + scripts/db.mjs | 194 + scripts/open-report.mjs | 36 + scripts/serve-report.mjs | 102 + scripts/test-catalog.mjs | 317 + security/TravelHub-VAPT-report.pdf | Bin 0 -> 186938 bytes security/VAPT-REPORT.md | 396 + security/vapt-report.html | 364 + 129 files changed, 25424 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TEST-CATALOG.md create mode 100644 TESTING.md create mode 100644 backend/.env.example create mode 100644 backend/cmd/api/main.go create mode 100644 backend/coverage create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/auth/auth.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/handlers/bookings.go create mode 100644 backend/internal/handlers/flights.go create mode 100644 backend/internal/handlers/handlers.go create mode 100644 backend/internal/handlers/handlers_test.go create mode 100644 backend/internal/handlers/session.go create mode 100644 backend/internal/httpapi/router.go create mode 100644 backend/internal/middleware/middleware.go create mode 100644 backend/internal/middleware/middleware_test.go create mode 100644 backend/internal/models/models.go create mode 100644 backend/internal/repository/booking_repo.go create mode 100644 backend/internal/repository/booking_repo_test.go create mode 100644 backend/internal/repository/flight_repo.go create mode 100644 backend/internal/repository/flight_repo_test.go create mode 100644 backend/internal/repository/identity_repo.go create mode 100644 backend/internal/repository/identity_repo_test.go create mode 100644 backend/internal/repository/reference_repo.go create mode 100644 backend/internal/repository/repository.go create mode 100644 backend/internal/testsupport/testsupport.go create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/migrations/002_seed.sql create mode 100644 backend/migrations/003_demo_bookings.sql create mode 100644 e2e/fixtures/accounts.ts create mode 100644 e2e/fixtures/api-mock.ts create mode 100644 e2e/fixtures/evidence.ts create mode 100644 e2e/fixtures/test.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/pages/bookings.page.ts create mode 100644 e2e/pages/checkout.page.ts create mode 100644 e2e/pages/dashboard.page.ts create mode 100644 e2e/pages/login.page.ts create mode 100644 e2e/pages/search.page.ts create mode 100644 e2e/pages/shell.page.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/reporters/evidence.ts create mode 100644 e2e/reporters/render-html.ts create mode 100644 e2e/reporters/report-model.ts create mode 100644 e2e/scripts/report-pdf.mjs create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/booking.spec.ts create mode 100644 e2e/tests/dashboard.spec.ts create mode 100644 e2e/tests/responsive.spec.ts create mode 100644 e2e/tests/search.spec.ts create mode 100644 e2e/tests/simulation.spec.ts create mode 100644 e2e/tests/tenant-isolation.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 frontend/.editorconfig create mode 100644 frontend/.gitignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/launch.json create mode 100644 frontend/.vscode/tasks.json create mode 100644 frontend/README.md create mode 100644 frontend/angular.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/proxy.conf.json create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/app/app.config.ts create mode 100644 frontend/src/app/app.routes.ts create mode 100644 frontend/src/app/app.ts create mode 100644 frontend/src/app/core/api.service.spec.ts create mode 100644 frontend/src/app/core/api.service.ts create mode 100644 frontend/src/app/core/auth.guard.spec.ts create mode 100644 frontend/src/app/core/auth.guard.ts create mode 100644 frontend/src/app/core/auth.interceptor.spec.ts create mode 100644 frontend/src/app/core/auth.interceptor.ts create mode 100644 frontend/src/app/core/auth.service.spec.ts create mode 100644 frontend/src/app/core/auth.service.ts create mode 100644 frontend/src/app/core/models.ts create mode 100644 frontend/src/app/features/bookings/booking-detail.html create mode 100644 frontend/src/app/features/bookings/booking-detail.scss create mode 100644 frontend/src/app/features/bookings/booking-detail.spec.ts create mode 100644 frontend/src/app/features/bookings/booking-detail.ts create mode 100644 frontend/src/app/features/bookings/bookings.html create mode 100644 frontend/src/app/features/bookings/bookings.scss create mode 100644 frontend/src/app/features/bookings/bookings.spec.ts create mode 100644 frontend/src/app/features/bookings/bookings.ts create mode 100644 frontend/src/app/features/checkout/checkout.html create mode 100644 frontend/src/app/features/checkout/checkout.scss create mode 100644 frontend/src/app/features/checkout/checkout.spec.ts create mode 100644 frontend/src/app/features/checkout/checkout.ts create mode 100644 frontend/src/app/features/dashboard/dashboard.html create mode 100644 frontend/src/app/features/dashboard/dashboard.scss create mode 100644 frontend/src/app/features/dashboard/dashboard.spec.ts create mode 100644 frontend/src/app/features/dashboard/dashboard.ts create mode 100644 frontend/src/app/features/login/login.html create mode 100644 frontend/src/app/features/login/login.scss create mode 100644 frontend/src/app/features/login/login.spec.ts create mode 100644 frontend/src/app/features/login/login.ts create mode 100644 frontend/src/app/features/search/search.html create mode 100644 frontend/src/app/features/search/search.scss create mode 100644 frontend/src/app/features/search/search.spec.ts create mode 100644 frontend/src/app/features/search/search.ts create mode 100644 frontend/src/app/layout/shell.html create mode 100644 frontend/src/app/layout/shell.scss create mode 100644 frontend/src/app/layout/shell.spec.ts create mode 100644 frontend/src/app/layout/shell.ts create mode 100644 frontend/src/app/shared/duration.pipe.spec.ts create mode 100644 frontend/src/app/shared/duration.pipe.ts create mode 100644 frontend/src/index.html create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/styles.scss create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.spec.json create mode 100644 instruction.txt create mode 100644 memory.md create mode 100644 package.json create mode 100644 scripts/db.mjs create mode 100644 scripts/open-report.mjs create mode 100644 scripts/serve-report.mjs create mode 100644 scripts/test-catalog.mjs create mode 100644 security/TravelHub-VAPT-report.pdf create mode 100644 security/VAPT-REPORT.md create mode 100644 security/vapt-report.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4751b3a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa07a1c --- /dev/null +++ b/README.md @@ -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 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. diff --git a/TEST-CATALOG.md b/TEST-CATALOG.md new file mode 100644 index 0000000..43bd156 --- /dev/null +++ b/TEST-CATALOG.md @@ -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 + diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..c451734 --- /dev/null +++ b/TESTING.md @@ -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//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 `
` 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. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..01c0f7e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..5e7ec0a --- /dev/null +++ b/backend/cmd/api/main.go @@ -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) +} diff --git a/backend/coverage b/backend/coverage new file mode 100644 index 0000000..0597e28 --- /dev/null +++ b/backend/coverage @@ -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 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..eaaf106 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..75f438e --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..dcd5485 --- /dev/null +++ b/backend/internal/auth/auth.go @@ -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" +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..efeb2a0 --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..7d8cff8 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -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) + } +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..cb5c159 --- /dev/null +++ b/backend/internal/database/database.go @@ -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 +} diff --git a/backend/internal/handlers/bookings.go b/backend/internal/handlers/bookings.go new file mode 100644 index 0000000..9464cd2 --- /dev/null +++ b/backend/internal/handlers/bookings.go @@ -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) +} diff --git a/backend/internal/handlers/flights.go b/backend/internal/handlers/flights.go new file mode 100644 index 0000000..1d5b76f --- /dev/null +++ b/backend/internal/handlers/flights.go @@ -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 +} diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go new file mode 100644 index 0000000..9e574f4 --- /dev/null +++ b/backend/internal/handlers/handlers.go @@ -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"}) +} diff --git a/backend/internal/handlers/handlers_test.go b/backend/internal/handlers/handlers_test.go new file mode 100644 index 0000000..ba98926 --- /dev/null +++ b/backend/internal/handlers/handlers_test.go @@ -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) + } +} diff --git a/backend/internal/handlers/session.go b/backend/internal/handlers/session.go new file mode 100644 index 0000000..2ca4849 --- /dev/null +++ b/backend/internal/handlers/session.go @@ -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}) +} diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go new file mode 100644 index 0000000..d35be16 --- /dev/null +++ b/backend/internal/httpapi/router.go @@ -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), + ) +} diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go new file mode 100644 index 0000000..fecd04c --- /dev/null +++ b/backend/internal/middleware/middleware.go @@ -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 +} diff --git a/backend/internal/middleware/middleware_test.go b/backend/internal/middleware/middleware_test.go new file mode 100644 index 0000000..b268e76 --- /dev/null +++ b/backend/internal/middleware/middleware_test.go @@ -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) + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..39c3e30 --- /dev/null +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/repository/booking_repo.go b/backend/internal/repository/booking_repo.go new file mode 100644 index 0000000..cc5930f --- /dev/null +++ b/backend/internal/repository/booking_repo.go @@ -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() +} diff --git a/backend/internal/repository/booking_repo_test.go b/backend/internal/repository/booking_repo_test.go new file mode 100644 index 0000000..b5f6b84 --- /dev/null +++ b/backend/internal/repository/booking_repo_test.go @@ -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) + } +} diff --git a/backend/internal/repository/flight_repo.go b/backend/internal/repository/flight_repo.go new file mode 100644 index 0000000..3719816 --- /dev/null +++ b/backend/internal/repository/flight_repo.go @@ -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 +} diff --git a/backend/internal/repository/flight_repo_test.go b/backend/internal/repository/flight_repo_test.go new file mode 100644 index 0000000..dec64c9 --- /dev/null +++ b/backend/internal/repository/flight_repo_test.go @@ -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) + } +} diff --git a/backend/internal/repository/identity_repo.go b/backend/internal/repository/identity_repo.go new file mode 100644 index 0000000..d3bd802 --- /dev/null +++ b/backend/internal/repository/identity_repo.go @@ -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 +} diff --git a/backend/internal/repository/identity_repo_test.go b/backend/internal/repository/identity_repo_test.go new file mode 100644 index 0000000..7ad8ab5 --- /dev/null +++ b/backend/internal/repository/identity_repo_test.go @@ -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") + } +} diff --git a/backend/internal/repository/reference_repo.go b/backend/internal/repository/reference_repo.go new file mode 100644 index 0000000..a6834e6 --- /dev/null +++ b/backend/internal/repository/reference_repo.go @@ -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 +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go new file mode 100644 index 0000000..a74936b --- /dev/null +++ b/backend/internal/repository/repository.go @@ -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" +} diff --git a/backend/internal/testsupport/testsupport.go b/backend/internal/testsupport/testsupport.go new file mode 100644 index 0000000..c393d71 --- /dev/null +++ b/backend/internal/testsupport/testsupport.go @@ -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))) +} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..4a48e33 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -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); diff --git a/backend/migrations/002_seed.sql b/backend/migrations/002_seed.sql new file mode 100644 index 0000000..5a2fa9b --- /dev/null +++ b/backend/migrations/002_seed.sql @@ -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 +); diff --git a/backend/migrations/003_demo_bookings.sql b/backend/migrations/003_demo_bookings.sql new file mode 100644 index 0000000..7081a18 --- /dev/null +++ b/backend/migrations/003_demo_bookings.sql @@ -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 $$; diff --git a/e2e/fixtures/accounts.ts b/e2e/fixtures/accounts.ts new file mode 100644 index 0000000..70adff1 --- /dev/null +++ b/e2e/fixtures/accounts.ts @@ -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', +}; diff --git a/e2e/fixtures/api-mock.ts b/e2e/fixtures/api-mock.ts new file mode 100644 index 0000000..2dcb96d --- /dev/null +++ b/e2e/fixtures/api-mock.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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$(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 { + 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 { + await page.addInitScript( + ([slug]) => { + localStorage.setItem( + 'travelhub.credentials', + JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }), + ); + }, + [tenant], + ); +} diff --git a/e2e/fixtures/evidence.ts b/e2e/fixtures/evidence.ts new file mode 100644 index 0000000..a759aee --- /dev/null +++ b/e2e/fixtures/evidence.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts new file mode 100644 index 0000000..42f5fce --- /dev/null +++ b/e2e/fixtures/test.ts @@ -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; + + /** 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({ + 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()}`; +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..8d9ed5c --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..f8e3e1a --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/pages/bookings.page.ts b/e2e/pages/bookings.page.ts new file mode 100644 index 0000000..e3a0b54 --- /dev/null +++ b/e2e/pages/bookings.page.ts @@ -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 { + await this.page.goto('/bookings'); + await this.settled(); + } + + /** Waits for either a populated table or the empty state. */ + async settled(): Promise { + 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 { + await this.rowFor(reference).getByRole('link', { name: 'View' }).click(); + await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/); + } + + async filterBy(status: 'all' | 'confirmed' | 'cancelled'): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/e2e/pages/checkout.page.ts b/e2e/pages/checkout.page.ts new file mode 100644 index 0000000..840f9da --- /dev/null +++ b/e2e/pages/checkout.page.ts @@ -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 { + await expect(this.travellerFieldsets.first()).toBeVisible(); + } + + async totalAmount(): Promise { + 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 { + // 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 { + for (let i = 0; i < travellers.length; i++) { + await this.fillTraveller(i, travellers[i]); + } + } + + async removeTraveller(index: number): Promise { + await this.travellerFieldsets.nth(index).getByRole('button', { name: 'Remove' }).click(); + } + + /** Confirms and waits for the confirmation screen. */ + async confirmBooking(): Promise { + await this.confirm.click(); + await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/); + } +} diff --git a/e2e/pages/dashboard.page.ts b/e2e/pages/dashboard.page.ts new file mode 100644 index 0000000..f40ab28 --- /dev/null +++ b/e2e/pages/dashboard.page.ts @@ -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 { + await this.page.goto('/dashboard'); + await this.settled(); + } + + async settled(): Promise { + 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 { + 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, '')); + } +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 0000000..aa1003a --- /dev/null +++ b/e2e/pages/login.page.ts @@ -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 { + 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 { + await this.demoChips.filter({ hasText: account.email }).first().click(); + } + + async fill(account: Account, password = account.password): Promise { + 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 { + 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 { + await this.fill(account, password); + await this.submit.click(); + await expect(this.error).toBeVisible(); + return (await this.error.innerText()).trim(); + } +} diff --git a/e2e/pages/search.page.ts b/e2e/pages/search.page.ts new file mode 100644 index 0000000..ed5469b --- /dev/null +++ b/e2e/pages/search.page.ts @@ -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 { + const text = (await this.price.innerText()).replace(/[^0-9.]/g, ''); + return Number(text); + } + + async seatsLeft(): Promise { + const text = await this.seatsBadge.innerText(); + return Number(text.replace(/[^0-9]/g, '')); + } + + async flightNumber(): Promise { + return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim(); + } + + async durationMinutes(): Promise { + 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 { + 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 { + await expect(this.page.locator('.skeleton-card')).toHaveCount(0); + await expect(this.resultSummary).not.toHaveText(/Searching/); + } + + async searchRoute(from: string, to: string): Promise { + await this.origin.selectOption(from); + await this.destination.selectOption(to); + await this.searchButton.click(); + await this.settled(); + } + + async setTravellers(n: number): Promise { + await this.travellers.selectOption(String(n)); + await this.searchButton.click(); + await this.settled(); + } + + async sortBy(value: 'price' | 'duration' | 'departure'): Promise { + await this.sort.selectOption(value); + await this.settled(); + } + + /** Reads the "Showing 1-10 of 42 flights" line. */ + async totalResults(): Promise { + 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()); + } +} diff --git a/e2e/pages/shell.page.ts b/e2e/pages/shell.page.ts new file mode 100644 index 0000000..ffad330 --- /dev/null +++ b/e2e/pages/shell.page.ts @@ -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 { + await this.page.locator('.nav-link[href="/dashboard"]').click(); + await this.page.waitForURL('**/dashboard'); + } + + async goToSearch(): Promise { + await this.page.locator('.nav-link[href="/search"]').click(); + await this.page.waitForURL('**/search'); + } + + async goToBookings(): Promise { + await this.page.locator('.nav-link[href="/bookings"]').click(); + await this.page.waitForURL('**/bookings'); + } + + async signOutAndWait(): Promise { + await this.signOut.click(); + await this.page.waitForURL('**/login'); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..bd4151a --- /dev/null +++ b/e2e/playwright.config.ts @@ -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', + }, + ], +}); diff --git a/e2e/reporters/evidence.ts b/e2e/reporters/evidence.ts new file mode 100644 index 0000000..a7b34b4 --- /dev/null +++ b/e2e/reporters/evidence.ts @@ -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(); + + 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; + } 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 { + const durationMs = Date.now() - this.startedAt; + + const summary = { + generatedAt: new Date().toISOString(), + outcome: result.status, + durationMs, + total: this.scenarios.length, + passed: this.scenarios.filter((s) => s.status === 'passed').length, + failed: this.scenarios.filter((s) => s.status === 'failed' || s.status === 'timedOut').length, + skipped: this.scenarios.filter((s) => s.status === 'skipped').length, + narrated: this.scenarios.filter((s) => s.steps.some((step) => step.facts)).length, + screenshots: this.scenarios.reduce( + (n, s) => n + s.steps.filter((step) => step.shot).length, + 0, + ), + }; + + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync( + join(OUT_DIR, 'report.json'), + JSON.stringify({ summary, scenarios: this.scenarios }, null, 2), + 'utf8', + ); + writeFileSync(join(OUT_DIR, 'report.md'), renderMarkdown(summary, this.scenarios), 'utf8'); + writeFileSync( + join(OUT_DIR, 'index.html'), + renderHtml(summary, this.scenarios, this.meta), + 'utf8', + ); + + // An absolute file:// URL, because a relative path is ambiguous: the suite + // runs with its cwd in e2e/, but people launch it from the repo root. + const file = join(OUT_DIR, 'index.html'); + const url = pathToFileURL(file).href; + + console.log( + [ + '', + 'Evidence report — what passed, with data and screenshots:', + ` ${url}`, + ` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` + + `${summary.narrated} scenarios with recorded data`, + '', + ' Open it: npm run report', + ' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href, + '', + " (`npx playwright show-report` opens Playwright's own report, which is a", + ' different artifact — it is for debugging failures.)', + ].join('\n'), + ); + } +} + +// --- helpers --------------------------------------------------------------- + +function slug(value: string): string { + return value.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 60); +} + +function safeRead(path: string): string { + try { + return readFileSync(path, 'utf8'); + } catch { + return ''; + } +} + + + + +// --- markdown -------------------------------------------------------------- + +function renderMarkdown(summary: Summary, scenarios: Scenario[]): string { + const lines: string[] = [ + '# TravelHub end-to-end run report', + '', + `Generated ${summary.generatedAt} · ${summary.passed}/${summary.total} passed · ` + + `${friendly(summary.durationMs)}`, + '', + ]; + + for (const file of group(scenarios)) { + lines.push(`## ${areaFor(file.name).title}`, '', `_${areaFor(file.name).blurb}_`, ''); + for (const scenario of file.items) { + const mark = scenario.status === 'passed' ? '✅' : scenario.status === 'skipped' ? '⏭️' : '❌'; + const heading = sentenceCase(scenario.title); + lines.push(`### ${mark} ${heading}`, ''); + lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, ''); + + if (scenario.steps.length === 0) { + lines.push('_No evidence captured._', ''); + continue; + } + + for (const step of scenario.steps) { + lines.push(`**${step.seq}. ${step.name}**`, ''); + if (step.facts) { + lines.push('| Field | Value |', '| ----- | ----- |'); + for (const [key, value] of Object.entries(step.facts)) { + lines.push(`| ${label(key)} | ${formatValue(value)} |`); + } + lines.push(''); + } + if (step.shot) { + lines.push(`![${step.name}](${step.shot})`, ''); + } + } + } + } + + return lines.join('\n'); +} + + diff --git a/e2e/reporters/render-html.ts b/e2e/reporters/render-html.ts new file mode 100644 index 0000000..b89ea51 --- /dev/null +++ b/e2e/reporters/render-html.ts @@ -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) => + `
  • 2.${a.index}${escapeHtml( + a.area.title, + )}${a.items.length} tests
  • `, + ) + .join(''); + + const summaryRows = areas + .map((a) => { + const rate = Math.round((a.passed / Math.max(a.items.length, 1)) * 100); + return ` + + 2.${a.index} + ${escapeHtml(a.area.title)} + ${a.items.length} + ${a.passed} + ${a.failed || '—'} + +
    + + ${a.failed ? 'Failed' : 'Passed'} + `; + }) + .join(''); + + return ` + + + + +TravelHub — Test Execution Report + + + +
    + +
    +
    Test execution report
    +

    TravelHub

    +

    Multi-tenant flight booking · end-to-end suite
    ${escapeHtml(stamp)}

    + +
    + ${allPassed ? '✓' : '!'} + + ${ + allPassed ? 'All tests passed' : `${summary.failed} of ${summary.total} tests failed` + }
    + ${summary.passed} of ${summary.total} scenarios verified in ${friendly( + summary.durationMs, + )} +
    +
    + +
    +
    Application under test
    ${escapeHtml(meta.baseURL)}
    +
    Browsers
    ${escapeHtml(meta.projects.join(', ') || 'chromium')}
    +
    Scenarios executed
    ${summary.total}
    +
    Duration
    ${friendly(summary.durationMs)}
    +
    Screenshots captured
    ${summary.screenshots}
    +
    Scenarios with recorded data
    ${summary.narrated}
    +
    +
    + +
    +
    1

    Summary

    +

    + 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. +

    + + + + + + + + + + ${summaryRows} + + + + + + + + + + +
    Area testedTestsPassedFailedPass rateResult
    Total${summary.total}${summary.passed}${summary.failed || '—'}${allPassed ? 'Passed' : 'Failed'}
    + +

    Contents

    +
      ${contents}
    +
    + +
    +
    2

    Detailed results

    +

    + 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. +

    + +
    + + + + + + + Passed + Failed + Not run + +
    + + ${areas.map(renderArea).join('')} +
    + +
    + Generated automatically by the TravelHub end-to-end suite + (npm run test:e2e) and reopened with npm run report. + 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. +
    +
    + + + + + +`; +} + +function renderArea(entry: AreaBlock): string { + const scenarios = entry.items + .map((scenario, i) => renderScenario(scenario, `2.${entry.index}.${i + 1}`)) + .join(''); + + return ` +
    +
    +
    + 2.${entry.index} +

    ${escapeHtml(entry.area.title)}

    + ${entry.passed}/${entry.items.length} passed +
    +

    ${escapeHtml(entry.area.blurb)}

    +
    + ${scenarios} +
    `; +} + +function renderScenario(scenario: Scenario, ref: string): string { + const hasFacts = scenario.steps.some((s) => s.facts); + const steps = + scenario.steps.length === 0 + ? '

    No screenshot was captured for this scenario.

    ' + : scenario.steps.map(renderStep).join(''); + + return ` +
    + + ${ref} + + ${scenario.suite ? `${escapeHtml(scenario.suite)}` : ''} + ${escapeHtml(sentenceCase(scenario.title))} + + + ${escapeHtml(scenario.project)} + ${friendly(scenario.durationMs)} + ${statusWord(scenario.status)} + + +
    + ${scenario.error ? `
    ${escapeHtml(scenario.error)}
    ` : ''} + ${steps} +
    +
    `; +} + +function renderStep(step: Step): string { + const facts = step.facts + ? `${Object.entries(step.facts) + .map( + ([key, value]) => + ``, + ) + .join('')}
    ${escapeHtml(label(key))}${escapeHtml(formatValue(value))}
    ` + : ''; + + const shot = step.shot + ? `
    + ${escapeHtml(step.name)} +
    Screen at this point · click to enlarge
    +
    ` + : ''; + + return ` +
    +

    ${escapeHtml(step.seq)}${escapeHtml(step.name)}

    + ${facts} + ${shot} +
    `; +} + +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
    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
    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; + } + });`; diff --git a/e2e/reporters/report-model.ts b/e2e/reporters/report-model.ts new file mode 100644 index 0000000..1b1b39b --- /dev/null +++ b/e2e/reporters/report-model.ts @@ -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; +} + +/** 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 = { + '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(); + 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, '"'); +} + +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'; +} diff --git a/e2e/scripts/report-pdf.mjs b/e2e/scripts/report-pdf.mjs new file mode 100644 index 0000000..cabaa0e --- /dev/null +++ b/e2e/scripts/report-pdf.mjs @@ -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
    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 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 = + '
    ' + + 'TravelHub — test execution report' + + 'Page of ' + + '
    '; + + mkdirSync(dirname(OUT), { recursive: true }); + + await page.pdf({ + path: OUT, + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '
    ', // 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(); +} diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..8565364 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/booking.spec.ts b/e2e/tests/booking.spec.ts new file mode 100644 index 0000000..f845ccc --- /dev/null +++ b/e2e/tests/booking.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..46e56df --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -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$/); + }); +}); diff --git a/e2e/tests/responsive.spec.ts b/e2e/tests/responsive.spec.ts new file mode 100644 index 0000000..28d874e --- /dev/null +++ b/e2e/tests/responsive.spec.ts @@ -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 { + 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); + }); +}); diff --git a/e2e/tests/search.spec.ts b/e2e/tests/search.spec.ts new file mode 100644 index 0000000..7603e20 --- /dev/null +++ b/e2e/tests/search.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/simulation.spec.ts b/e2e/tests/simulation.spec.ts new file mode 100644 index 0000000..bb2ce8b --- /dev/null +++ b/e2e/tests/simulation.spec.ts @@ -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: '', lastName: 'bold' }, + ], + }), + }); + + await page.goto('/bookings/b-1'); + + const row = page.locator('table.data tbody tr').first(); + await expect(row).toContainText('bold'); + 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(); + 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'); + }); +}); diff --git a/e2e/tests/tenant-isolation.spec.ts b/e2e/tests/tenant-isolation.spec.ts new file mode 100644 index 0000000..a82ae31 --- /dev/null +++ b/e2e/tests/tenant-isolation.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..d26c019 --- /dev/null +++ b/e2e/tsconfig.json @@ -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"] +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/frontend/.gitignore @@ -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 diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -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" + } + ] +} diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json new file mode 100644 index 0000000..244306f --- /dev/null +++ b/frontend/.vscode/tasks.json @@ -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)" + } + } + } + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5c62353 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..a2c11b1 --- /dev/null +++ b/frontend/angular.json @@ -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" + } + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d4d37dd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8069 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.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" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2201.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2201.8.tgz", + "integrity": "sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.8.tgz", + "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.5", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.8.tgz", + "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "jsonc-parser": "3.3.1", + "magic-string": "1.0.0", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.1.8.tgz", + "integrity": "sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2201.8", + "@babel/core": "8.0.1", + "@babel/helper-annotate-as-pure": "8.0.0", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "6.1.1", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.4.3", + "browserslist": "^4.26.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "magic-string": "1.0.0", + "mrmime": "2.0.1", + "oxc-parser": "0.142.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.5", + "piscina": "5.2.0", + "rolldown": "1.2.0", + "sass": "1.101.0", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.1.5", + "watchpack": "2.5.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.6" + }, + "peerDependencies": { + "@angular/compiler": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.1.8", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^22.0.0", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cli": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.1.8.tgz", + "integrity": "sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2201.8", + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "@inquirer/prompts": "8.5.2", + "@listr2/prompt-adapter-inquirer": "4.2.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@schematics/angular": "22.1.8", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "npm-package-arg": "14.0.0", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.8.5", + "yargs": "18.1.0", + "zod": "4.4.3" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", + "integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", + "integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", + "integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "8.0.1", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", + "integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", + "integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", + "integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/animations": "22.1.6", + "@angular/common": "22.1.6", + "@angular/core": "22.1.6" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", + "integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.5.tgz", + "integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.5", + "@babel/types": "^8.0.5", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.5.tgz", + "integrity": "sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.5.tgz", + "integrity": "sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz", + "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.5.tgz", + "integrity": "sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.5", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", + "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "11.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", + "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", + "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", + "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", + "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", + "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", + "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", + "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", + "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", + "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", + "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", + "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", + "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", + "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", + "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", + "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", + "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", + "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", + "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", + "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", + "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", + "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", + "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", + "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", + "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", + "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", + "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", + "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", + "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", + "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", + "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", + "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", + "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", + "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@schematics/angular": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.8.tgz", + "integrity": "sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.3.tgz", + "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", + "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/listr2": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz", + "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^6.1.1", + "log-update": "^8.0.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/lmdb": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", + "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.6", + "@lmdb/lmdb-darwin-x64": "3.5.6", + "@lmdb/lmdb-linux-arm": "3.5.6", + "@lmdb/lmdb-linux-arm64": "3.5.6", + "@lmdb/lmdb-linux-x64": "3.5.6", + "@lmdb/lmdb-win32-arm64": "3.5.6", + "@lmdb/lmdb-win32-x64": "3.5.6" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-8.0.0.tgz", + "integrity": "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.3.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", + "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-package-arg": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-14.0.0.tgz", + "integrity": "sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oxc-parser": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", + "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.142.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.142.0", + "@oxc-parser/binding-android-arm64": "0.142.0", + "@oxc-parser/binding-darwin-arm64": "0.142.0", + "@oxc-parser/binding-darwin-x64": "0.142.0", + "@oxc-parser/binding-freebsd-x64": "0.142.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", + "@oxc-parser/binding-linux-arm64-musl": "0.142.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-musl": "0.142.0", + "@oxc-parser/binding-openharmony-arm64": "0.142.0", + "@oxc-parser/binding-wasm32-wasi": "0.142.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", + "@oxc-parser/binding-win32-x64-msvc": "0.142.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", + "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.140.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.0", + "@rolldown/binding-darwin-arm64": "1.2.0", + "@rolldown/binding-darwin-x64": "1.2.0", + "@rolldown/binding-freebsd-x64": "1.2.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", + "@rolldown/binding-linux-arm64-gnu": "1.2.0", + "@rolldown/binding-linux-arm64-musl": "1.2.0", + "@rolldown/binding-linux-ppc64-gnu": "1.2.0", + "@rolldown/binding-linux-s390x-gnu": "1.2.0", + "@rolldown/binding-linux-x64-gnu": "1.2.0", + "@rolldown/binding-linux-x64-musl": "1.2.0", + "@rolldown/binding-openharmony-arm64": "1.2.0", + "@rolldown/binding-wasm32-wasi": "1.2.0", + "@rolldown/binding-win32-arm64-msvc": "1.2.0", + "@rolldown/binding-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.140.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", + "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", + "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6350917 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..d1825a9 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false, + "changeOrigin": true, + "logLevel": "warn" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 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 Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..75cef04 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -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])), + ], +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..f7f78d2 --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -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: '' }, +]; diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts new file mode 100644 index 0000000..f64042c --- /dev/null +++ b/frontend/src/app/app.ts @@ -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: '', +}) +export class App {} diff --git a/frontend/src/app/core/api.service.spec.ts b/frontend/src/app/core/api.service.spec.ts new file mode 100644 index 0000000..5d50853 --- /dev/null +++ b/frontend/src/app/core/api.service.spec.ts @@ -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([]); + }); +}); diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts new file mode 100644 index 0000000..2f53600 --- /dev/null +++ b/frontend/src/app/core/api.service.ts @@ -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> { + 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>('/api/flights', { params }); + } + + getFlight(id: string): Observable { + return this.http.get(`/api/flights/${id}`); + } + + listAirports(): Observable { + return this.http.get('/api/airports'); + } + + listAirlines(): Observable { + return this.http.get('/api/airlines'); + } + + listBookings(status = '', limit = 25, offset = 0): Observable> { + let params = new HttpParams().set('limit', limit).set('offset', offset); + if (status) { + params = params.set('status', status); + } + return this.http.get>('/api/bookings', { params }); + } + + getBooking(id: string): Observable { + return this.http.get(`/api/bookings/${id}`); + } + + createBooking(request: CreateBookingRequest): Observable { + return this.http.post('/api/bookings', request); + } + + cancelBooking(id: string): Observable { + return this.http.post(`/api/bookings/${id}/cancel`, {}); + } + + dashboard(): Observable { + return this.http.get('/api/dashboard'); + } +} diff --git a/frontend/src/app/core/auth.guard.spec.ts b/frontend/src/app/core/auth.guard.spec.ts new file mode 100644 index 0000000..376b053 --- /dev/null +++ b/frontend/src/app/core/auth.guard.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/app/core/auth.guard.ts b/frontend/src/app/core/auth.guard.ts new file mode 100644 index 0000000..1779b59 --- /dev/null +++ b/frontend/src/app/core/auth.guard.ts @@ -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; +}; diff --git a/frontend/src/app/core/auth.interceptor.spec.ts b/frontend/src/app/core/auth.interceptor.spec.ts new file mode 100644 index 0000000..dc42cd1 --- /dev/null +++ b/frontend/src/app/core/auth.interceptor.spec.ts @@ -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((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); + }); +}); diff --git a/frontend/src/app/core/auth.interceptor.ts b/frontend/src/app/core/auth.interceptor.ts new file mode 100644 index 0000000..ba325de --- /dev/null +++ b/frontend/src/app/core/auth.interceptor.ts @@ -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); + }), + ); +}; diff --git a/frontend/src/app/core/auth.service.spec.ts b/frontend/src/app/core/auth.service.spec.ts new file mode 100644 index 0000000..540f3b7 --- /dev/null +++ b/frontend/src/app/core/auth.service.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts new file mode 100644 index 0000000..1ac61bc --- /dev/null +++ b/frontend/src/app/core/auth.service.ts @@ -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(readStored()); + private readonly sessionSignal = signal(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('/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 { + const candidate: StoredCredentials = { + tenant, + basic: btoa(`${email}:${password}`), + }; + + const session = await firstValueFrom( + this.http.get('/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 { + if (this.sessionSignal() !== null) { + return true; + } + if (this.credentials() === null) { + return false; + } + + try { + const session = await firstValueFrom(this.http.get('/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; + 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; + } +} diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts new file mode 100644 index 0000000..9bf05ec --- /dev/null +++ b/frontend/src/app/core/models.ts @@ -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 { + 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; +} diff --git a/frontend/src/app/features/bookings/booking-detail.html b/frontend/src/app/features/bookings/booking-detail.html new file mode 100644 index 0000000..acc4366 --- /dev/null +++ b/frontend/src/app/features/bookings/booking-detail.html @@ -0,0 +1,190 @@ +@if (loading()) { +
    +} @else if (notFound() || booking() === null) { +
    +

    Booking not found

    +

    It may belong to another agency, or have been removed.

    +

    + Back to bookings +

    +
    +} @else { + @let b = booking()!; + + @if (justBooked() && b.status === 'confirmed') { +
    + +
    + Booking confirmed. + Reference {{ b.bookingReference }} — a confirmation would be sent + to {{ b.contactEmail }}. +
    +
    + } + +
    +
    +

    Booking {{ b.bookingReference }}

    +

    + Created {{ b.createdAt | date: 'd MMM y, HH:mm' }} by {{ b.bookedBy }} +

    +
    +
    + {{ b.status }} + ← All bookings +
    +
    + + @if (error()) { + + } + +
    +
    + +
    +

    Itinerary

    +
    +
    + {{ b.flight.airline.iataCode }} +
    +
    + {{ b.flight.airline.name }} + + {{ b.flight.flightNumber }} · {{ b.flight.aircraft }} · + {{ cabinLabel(b.flight.cabinClass) }} + +
    + +
    +
    + {{ b.flight.departureTime | date: 'HH:mm' }} + {{ b.flight.origin.iataCode }} + + {{ b.flight.origin.city }} · {{ b.flight.origin.name }} + + {{ b.flight.departureTime | date: 'EEE d MMM y' }} +
    + +
    + {{ b.flight.durationMinutes | duration }} + +
    + +
    + {{ b.flight.arrivalTime | date: 'HH:mm' }} + {{ b.flight.destination.iataCode }} + + {{ b.flight.destination.city }} · {{ b.flight.destination.name }} + + {{ b.flight.arrivalTime | date: 'EEE d MMM y' }} +
    +
    +
    +
    +
    +
    + + +
    +
    +

    Travellers

    + {{ b.passengerCount }} on this booking +
    +
    + + + + + + + + + + + @for (p of b.passengers; track p.id) { + + + + + + + } + +
    NameDate of birthPassportSeat
    {{ p.firstName }} {{ p.lastName }} + {{ p.dateOfBirth ? (p.dateOfBirth | date: 'd MMM y') : '—' }} + {{ p.passportNumber || '—' }}{{ p.seatNumber || 'Not assigned' }}
    +
    +
    +
    + + + +
    +} diff --git a/frontend/src/app/features/bookings/booking-detail.scss b/frontend/src/app/features/bookings/booking-detail.scss new file mode 100644 index 0000000..909af2f --- /dev/null +++ b/frontend/src/app/features/bookings/booking-detail.scss @@ -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; + } +} diff --git a/frontend/src/app/features/bookings/booking-detail.spec.ts b/frontend/src/app/features/bookings/booking-detail.spec.ts new file mode 100644 index 0000000..c3ec6e0 --- /dev/null +++ b/frontend/src/app/features/bookings/booking-detail.spec.ts @@ -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 = of(booking()); + cancelled: Observable = of(booking('cancelled')); + cancelCalls: string[] = []; + + getBooking = () => this.detail; + cancelBooking = (id: string) => { + this.cancelCalls.push(id); + return this.cancelled; + }; +} + +describe('BookingDetailPage', () => { + let fixture: ComponentFixture; + 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'); + }); +}); diff --git a/frontend/src/app/features/bookings/booking-detail.ts b/frontend/src/app/features/bookings/booking-detail.ts new file mode 100644 index 0000000..e04d758 --- /dev/null +++ b/frontend/src/app/features/bookings/booking-detail.ts @@ -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(); + + readonly booking = signal(null); + readonly loading = signal(true); + readonly notFound = signal(false); + readonly cancelling = signal(false); + readonly error = signal(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); + }, + }); + } +} diff --git a/frontend/src/app/features/bookings/bookings.html b/frontend/src/app/features/bookings/bookings.html new file mode 100644 index 0000000..d31e644 --- /dev/null +++ b/frontend/src/app/features/bookings/bookings.html @@ -0,0 +1,105 @@ +
    +
    +

    Bookings

    +

    Every reservation sold by your agency.

    +
    + + New booking +
    + +
    + + + +
    + +@if (error()) { + +} + +@if (loading()) { +
    +} @else if (bookings().length === 0) { +
    +

    No bookings yet

    +

    Search for a flight to create the first reservation.

    +

    + Search flights +

    +
    +} @else { +
    + + + + + + + + + + + + + + + @for (booking of bookings(); track booking.id) { + + + + + + + + + + + } + +
    ReferenceRouteDepartureTravellersTotalStatusBooked by
    {{ booking.bookingReference }} + + {{ booking.flight.origin.iataCode }} → {{ booking.flight.destination.iataCode }} + + + {{ booking.flight.airline.iataCode }} {{ booking.flight.flightNumber }} + + {{ booking.flight.departureTime | date: 'd MMM y, HH:mm' }}{{ booking.passengerCount }} + {{ booking.totalAmount | currency: booking.currency : 'symbol' : '1.2-2' }} + + {{ booking.status }} + {{ booking.bookedBy }} + View +
    +
    + + @if (canPrev() || canNext()) { + + } +} diff --git a/frontend/src/app/features/bookings/bookings.scss b/frontend/src/app/features/bookings/bookings.scss new file mode 100644 index 0000000..b9ca36c --- /dev/null +++ b/frontend/src/app/features/bookings/bookings.scss @@ -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; +} diff --git a/frontend/src/app/features/bookings/bookings.spec.ts b/frontend/src/app/features/bookings/bookings.spec.ts new file mode 100644 index 0000000..dd3f86f --- /dev/null +++ b/frontend/src/app/features/bookings/bookings.spec.ts @@ -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> = 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; + 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(); + }); +}); diff --git a/frontend/src/app/features/bookings/bookings.ts b/frontend/src/app/features/bookings/bookings.ts new file mode 100644 index 0000000..2f8271f --- /dev/null +++ b/frontend/src/app/features/bookings/bookings.ts @@ -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([]); + readonly total = signal(0); + readonly offset = signal(0); + readonly status = signal<'' | 'confirmed' | 'cancelled'>(''); + readonly loading = signal(true); + readonly error = signal(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); + }, + }); + } +} diff --git a/frontend/src/app/features/checkout/checkout.html b/frontend/src/app/features/checkout/checkout.html new file mode 100644 index 0000000..1ebd5a3 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.html @@ -0,0 +1,186 @@ +@if (loading()) { +
    +} @else if (loadFailed() || flight() === null) { +
    +

    Flight not available

    +

    It may have been withdrawn from your agency's inventory.

    +

    + Back to search +

    +
    +} @else { + @let f = flight()!; + +
    +
    +

    Complete booking

    +

    Enter traveller details, then confirm.

    +
    + ← Back to search +
    + + @if (error()) { + + } + +
    + +
    +
    +
    +

    Travellers

    + +
    + +
    + @for (group of passengers.controls; track $index) { +
    + + Traveller {{ $index + 1 }} + @if (passengers.length > 1) { + + } + + +
    +
    + + + @if (showError($index, 'firstName')) { + Required. + } +
    + +
    + + + @if (showError($index, 'lastName')) { + Required. + } +
    + +
    + + + Optional +
    + +
    + + + Optional +
    +
    +
    + } +
    +
    + +
    +

    Contact details

    +
    +
    +
    + + + @if (form.controls.contactEmail.touched && form.controls.contactEmail.invalid) { + Enter a valid email address. + } +
    +
    + + + Optional +
    +
    +
    +
    + + +
    + + + +
    +} diff --git a/frontend/src/app/features/checkout/checkout.scss b/frontend/src/app/features/checkout/checkout.scss new file mode 100644 index 0000000..bf6ea98 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.scss @@ -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; + } +} diff --git a/frontend/src/app/features/checkout/checkout.spec.ts b/frontend/src/app/features/checkout/checkout.spec.ts new file mode 100644 index 0000000..61c3af9 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.spec.ts @@ -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 { + 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 = of(flight()); + created: Observable = 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; + 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'); + }); +}); diff --git a/frontend/src/app/features/checkout/checkout.ts b/frontend/src/app/features/checkout/checkout.ts new file mode 100644 index 0000000..f20113a --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.ts @@ -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(); + + readonly flight = signal(null); + readonly loading = signal(true); + readonly submitting = signal(false); + readonly error = signal(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 { + 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.'; +} diff --git a/frontend/src/app/features/dashboard/dashboard.html b/frontend/src/app/features/dashboard/dashboard.html new file mode 100644 index 0000000..fac0477 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,137 @@ +
    +
    +

    Good day, {{ userFirstName() }}

    +

    Trading overview for {{ tenantName() }}.

    +
    + + New booking +
    + +@if (error()) { + +} + +@if (loading()) { +
    + @for (n of [1, 2, 3, 4]; track n) { +
    + } +
    +} @else if (stats(); as s) { + +
    +
    + Revenue + + {{ s.totalRevenue | currency: s.currency : 'symbol' : '1.0-0' }} + + from {{ s.confirmedBookings }} confirmed bookings +
    + +
    + Bookings + {{ s.totalBookings }} + + {{ s.confirmedBookings }} confirmed + @if (s.cancelledBookings > 0) { + {{ s.cancelledBookings }} cancelled + } + +
    + +
    + Seats sold + {{ s.seatsSold }} + across all confirmed reservations +
    + +
    + Flights in stock + {{ s.flightsInStock }} + future departures in your inventory +
    +
    + +
    + +
    +
    +

    Recent bookings

    + View all +
    + + @if (s.recentBookings.length === 0) { +
    +

    No bookings yet

    +

    Reservations you sell will appear here.

    +
    + } @else { +
    + + + + + + + + + + + + + @for (booking of s.recentBookings; track booking.id) { + + + + + + + + + } + +
    ReferenceRouteTravellersTotalStatusCreated
    +
    + } +
    + + +
    +

    Top routes

    + + @if (topRoutes().length === 0) { +
    +

    Nothing sold yet

    +

    Your best-selling routes will rank here.

    +
    + } @else { +
    + @for (route of topRoutes(); track route.route) { +
    +
    + {{ route.route }} + + {{ route.revenue | currency: s.currency : 'symbol' : '1.0-0' }} + +
    + + + {{ route.bookings }} {{ route.bookings === 1 ? 'booking' : 'bookings' }} + +
    + } +
    + } +
    +
    +} diff --git a/frontend/src/app/features/dashboard/dashboard.scss b/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..f45ac1b --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.scss @@ -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); + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.spec.ts b/frontend/src/app/features/dashboard/dashboard.spec.ts new file mode 100644 index 0000000..cb2e7d7 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -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 { + 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 = of(stats()); + dashboard = () => this.result; +} + +describe('DashboardPage', () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/frontend/src/app/features/dashboard/dashboard.ts b/frontend/src/app/features/dashboard/dashboard.ts new file mode 100644 index 0000000..c6716c5 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.ts @@ -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(null); + readonly loading = signal(true); + readonly error = signal(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(() => 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))}%`; + } +} diff --git a/frontend/src/app/features/login/login.html b/frontend/src/app/features/login/login.html new file mode 100644 index 0000000..3b0f501 --- /dev/null +++ b/frontend/src/app/features/login/login.html @@ -0,0 +1,113 @@ +
    + + + + +
    +
    +
    +

    Sign in

    +

    Choose your agency, then enter your credentials.

    +
    + + @if (error()) { + + } + +
    +
    + + + @if (form.controls.tenant.touched && form.controls.tenant.invalid) { + Please choose an agency. + } +
    + +
    + + + @if (form.controls.email.touched && form.controls.email.invalid) { + Enter a valid email address. + } +
    + +
    + + + @if (form.controls.password.touched && form.controls.password.invalid) { + Enter your password. + } +
    + + +
    + +
    +

    Demo accounts

    +

    + Password demo1234. Sign in to each agency in turn and search JFK → LHR: the + same routes are priced differently per tenant. +

    +
    + + + + +
    +
    +
    +
    +
    diff --git a/frontend/src/app/features/login/login.scss b/frontend/src/app/features/login/login.scss new file mode 100644 index 0000000..8aa1307 --- /dev/null +++ b/frontend/src/app/features/login/login.scss @@ -0,0 +1,237 @@ +.auth { + display: grid; + grid-template-columns: 1.05fr 1fr; + min-height: 100vh; +} + +/* --- brand panel --------------------------------------------------------- */ + +.hero { + position: relative; + display: flex; + align-items: center; + padding: 56px; + color: #e8f0fb; + background: + radial-gradient(1100px 500px at 12% 18%, rgba(20, 112, 240, 0.5), transparent 62%), + radial-gradient(900px 460px at 88% 88%, rgba(0, 137, 123, 0.42), transparent 60%), + linear-gradient(150deg, #0b1727 0%, #13263d 58%, #0d2036 100%); + overflow: hidden; +} + +/* A faint great-circle arc, the one bit of travel iconography on the page. */ +.hero::after { + content: ''; + position: absolute; + inset: auto -18% -46% -18%; + height: 70%; + border-radius: 50%; + border: 1px dashed rgba(255, 255, 255, 0.14); + pointer-events: none; +} + +.hero-inner { + position: relative; + max-width: 470px; +} + +.brand { + display: flex; + align-items: center; + gap: 11px; + margin-bottom: 44px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--brand-500); + color: #fff; + font-size: 1.05rem; +} + +.brand-name { + font-size: 1.18rem; + font-weight: 700; + color: #fff; + letter-spacing: -0.02em; +} + +.hero h1 { + font-size: 2.7rem; + line-height: 1.12; + color: #fff; + margin-bottom: 18px; + letter-spacing: -0.03em; +} + +.hero p { + font-size: 1.02rem; + line-height: 1.6; + color: #b9cbe2; + margin-bottom: 30px; +} + +.hero-points { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 11px; +} + +.hero-points li { + position: relative; + padding-left: 27px; + color: #cfdcec; + font-size: 0.94rem; +} + +.hero-points li::before { + content: '✓'; + position: absolute; + left: 0; + top: 0; + display: grid; + place-items: center; + width: 18px; + height: 18px; + margin-top: 3px; + border-radius: 50%; + background: rgba(0, 179, 164, 0.22); + color: #5fdfd0; + font-size: 0.66rem; + font-weight: 700; +} + +/* --- form panel ---------------------------------------------------------- */ + +.panel { + display: flex; + align-items: center; + justify-content: center; + padding: 44px 32px; + background: var(--surface); + overflow-y: auto; +} + +.panel-inner { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 22px; +} + +.panel-head h2 { + font-size: 1.5rem; + margin-bottom: 5px; +} + +.btn-block { + width: 100%; + padding: 12px 18px; + margin-top: 4px; +} + +/* --- demo accounts ------------------------------------------------------- */ + +.demo { + padding-top: 22px; + border-top: 1px solid var(--border); +} + +.demo h3 { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 7px; +} + +.demo > p { + font-size: 0.83rem; + line-height: 1.5; + margin-bottom: 13px; +} + +.demo code { + padding: 1px 5px; + border-radius: 4px; + background: var(--surface-sunken); + border: 1px solid var(--border); + font-size: 0.8rem; +} + +.demo-grid { + display: grid; + gap: 8px; +} + +.demo-chip { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 2px 10px; + padding: 9px 13px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font: inherit; + text-align: left; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; +} + +.demo-chip:hover { + border-color: var(--brand-500); + background: var(--brand-50); +} + +.demo-agency { + font-size: 0.86rem; + font-weight: 600; +} + +.demo-chip .badge { + grid-row: 1 / span 2; + grid-column: 2; +} + +.demo-email { + grid-column: 1; + font-size: 0.78rem; + color: var(--text-muted); + font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; +} + +/* --- narrow screens ------------------------------------------------------ */ + +@media (max-width: 940px) { + .auth { + grid-template-columns: 1fr; + } + + .hero { + display: none; + } + + .panel { + padding: 32px 18px; + background: var(--surface-sunken); + } + + .panel-inner { + padding: 26px 22px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + } +} diff --git a/frontend/src/app/features/login/login.spec.ts b/frontend/src/app/features/login/login.spec.ts new file mode 100644 index 0000000..69b6f0e --- /dev/null +++ b/frontend/src/app/features/login/login.spec.ts @@ -0,0 +1,196 @@ +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 { LoginPage } from './login'; +import { AuthService } from '../../core/auth.service'; +import { PublicTenant, Session } from '../../core/models'; + +class FakeAuth { + tenants: Observable = of([ + { slug: 'skyline', name: 'Skyline Travel' }, + { slug: 'globetrotter', name: 'Globetrotter Tours' }, + ]); + signInResult: Promise = Promise.resolve({} as Session); + attempted: Array<[string, string, string]> = []; + + listTenants = () => this.tenants; + signIn = (tenant: string, email: string, password: string) => { + this.attempted.push([tenant, email, password]); + return this.signInResult; + }; +} + +describe('LoginPage', () => { + let fixture: ComponentFixture; + let page: LoginPage; + let auth: FakeAuth; + let navigatedTo: string[]; + + function create(): void { + fixture = TestBed.createComponent(LoginPage); + page = fixture.componentInstance; + fixture.detectChanges(); + } + + beforeEach(() => { + TestBed.resetTestingModule(); + auth = new FakeAuth(); + navigatedTo = []; + + TestBed.configureTestingModule({ + providers: [provideRouter([]), { provide: AuthService, useValue: auth }], + }); + + const router = TestBed.inject(Router); + router.navigateByUrl = ((url: string) => { + navigatedTo.push(String(url)); + return Promise.resolve(true); + }) as Router['navigateByUrl']; + }); + + it('loads the agency list on open', () => { + create(); + + expect(page.tenants().length).toBe(2); + expect(page.error()).toBeNull(); + }); + + it('preselects the only agency in a single-tenant deployment', () => { + auth.tenants = of([{ slug: 'solo', name: 'Solo Travel' }]); + create(); + + expect(page.form.controls.tenant.value).toBe('solo'); + }); + + it('does not preselect when there is a real choice to make', () => { + create(); + + expect(page.form.controls.tenant.value).toBe(''); + }); + + it('explains a backend that is not running', () => { + auth.tenants = throwError(() => new HttpErrorResponse({ status: 0 })); + create(); + + expect(page.error()).toContain('Go backend'); + }); + + it('fills the form from a demo chip', () => { + create(); + + page.useDemo('skyline', 'agent@skyline.test'); + + expect(page.form.getRawValue()).toEqual({ + tenant: 'skyline', + email: 'agent@skyline.test', + password: 'demo1234', + }); + expect(page.form.valid).toBe(true); + }); + + it('refuses to submit an incomplete form', async () => { + create(); + + await page.submit(); + + expect(auth.attempted).toEqual([]); + expect(page.form.controls.tenant.touched).toBe(true); + }); + + it('rejects a malformed email before calling the server', async () => { + create(); + page.form.setValue({ tenant: 'skyline', email: 'not-an-email', password: 'demo1234' }); + + await page.submit(); + + expect(auth.attempted).toEqual([]); + }); + + it('signs in and moves to the dashboard', async () => { + create(); + page.useDemo('skyline', 'agent@skyline.test'); + + await page.submit(); + + expect(auth.attempted).toEqual([['skyline', 'agent@skyline.test', 'demo1234']]); + expect(navigatedTo).toEqual(['/dashboard']); + expect(page.submitting()).toBe(false); + }); + + it('returns the user to the page they were trying to reach', async () => { + // Rebuild the injector with a route carrying ?returnTo=/bookings/abc. + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: AuthService, useValue: auth }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { queryParamMap: convertToParamMap({ returnTo: '/bookings/abc' }) }, + }, + }, + ], + }); + TestBed.inject(Router).navigateByUrl = ((url: string) => { + navigatedTo.push(String(url)); + return Promise.resolve(true); + }) as Router['navigateByUrl']; + + create(); + page.useDemo('skyline', 'agent@skyline.test'); + await page.submit(); + + expect(navigatedTo).toEqual(['/bookings/abc']); + }); + + it('reports wrong credentials in the agency`s terms', async () => { + auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 401 })); + create(); + page.useDemo('skyline', 'agent@skyline.test'); + + await page.submit(); + + expect(page.error()).toBe('Those credentials are not valid for this agency.'); + expect(navigatedTo).toEqual([]); + expect(page.submitting()).toBe(false); + }); + + it('reports an unreachable API distinctly from a bad password', async () => { + auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 0 })); + create(); + page.useDemo('skyline', 'agent@skyline.test'); + + await page.submit(); + + expect(page.error()).toContain('Go backend'); + }); + + it('falls back to a generic message for unexpected failures', async () => { + auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 500 })); + create(); + page.useDemo('skyline', 'agent@skyline.test'); + + await page.submit(); + + expect(page.error()).toContain('Something went wrong'); + }); + + it('renders one option per agency plus the placeholder', () => { + create(); + + const options: NodeListOf = + fixture.nativeElement.querySelectorAll('#tenant option'); + expect(options.length).toBe(3); + expect(options[1].textContent).toContain('Skyline Travel'); + }); + + it('shows four demo account shortcuts', () => { + create(); + + const chips = fixture.nativeElement.querySelectorAll('.demo-chip'); + expect(chips.length).toBe(4); + }); +}); diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts new file mode 100644 index 0000000..d4f13c0 --- /dev/null +++ b/frontend/src/app/features/login/login.ts @@ -0,0 +1,92 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { AuthService } from '../../core/auth.service'; +import { PublicTenant } from '../../core/models'; + +/** + * Sign-in screen. + * + * Choosing the agency is the first step, not an afterthought: the same email + * and password can exist at more than one tenant, and the credentials are only + * valid against the tenant they were issued for. + */ +@Component({ + selector: 'app-login', + imports: [ReactiveFormsModule], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class LoginPage { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + + readonly tenants = signal([]); + readonly submitting = signal(false); + readonly error = signal(null); + + readonly form = this.fb.nonNullable.group({ + tenant: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + constructor() { + this.auth.listTenants().subscribe({ + next: (tenants) => { + this.tenants.set(tenants); + // Preselect when there is only one agency, so the common single-tenant + // deployment does not make people pick from a list of one. + if (tenants.length === 1) { + this.form.controls.tenant.setValue(tenants[0].slug); + } + }, + error: () => + this.error.set('Could not reach the API. Is the Go backend running on port 8080?'), + }); + } + + /** Fills the form from the demo table so the sample is one click to try. */ + useDemo(tenant: string, email: string): void { + this.form.setValue({ tenant, email, password: 'demo1234' }); + this.error.set(null); + } + + async submit(): Promise { + if (this.form.invalid || this.submitting()) { + this.form.markAllAsTouched(); + return; + } + + this.submitting.set(true); + this.error.set(null); + + const { tenant, email, password } = this.form.getRawValue(); + + try { + await this.auth.signIn(tenant, email, password); + const returnTo = this.route.snapshot.queryParamMap.get('returnTo'); + await this.router.navigateByUrl(returnTo ?? '/dashboard'); + } catch (err: unknown) { + this.error.set(describe(err)); + } finally { + this.submitting.set(false); + } + } +} + +function describe(err: unknown): string { + if (err instanceof HttpErrorResponse) { + if (err.status === 401) { + return 'Those credentials are not valid for this agency.'; + } + if (err.status === 0) { + return 'Could not reach the API. Is the Go backend running on port 8080?'; + } + } + return 'Something went wrong signing in. Please try again.'; +} diff --git a/frontend/src/app/features/search/search.html b/frontend/src/app/features/search/search.html new file mode 100644 index 0000000..4d3d182 --- /dev/null +++ b/frontend/src/app/features/search/search.html @@ -0,0 +1,168 @@ +
    +
    +

    Search flights

    +

    Live fares from your agency's inventory.

    +
    +
    + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + +@if (error()) { + +} + + +
    + + @if (loading()) { + Searching… + } @else if (total() === 0) { + No flights match these filters + } @else { + Showing {{ showingFrom() }}–{{ showingTo() }} of {{ total() }} flights + } + + + +
    + + +@if (loading()) { +
    + @for (n of [1, 2, 3, 4]; track n) { +
    + } +
    +} @else if (flights().length === 0) { +
    +

    Nothing on this route yet

    +

    Try widening the dates, clearing the cabin filter, or resetting the search.

    +
    +} @else { +
    + @for (flight of flights(); track flight.id) { +
    + +
    + {{ flight.airline.iataCode }} + + {{ flight.airline.name }} + {{ flight.flightNumber }} · {{ flight.aircraft }} + +
    + + +
    +
    + {{ flight.departureTime | date: 'HH:mm' }} + {{ flight.origin.iataCode }} + {{ flight.origin.city }} +
    + +
    + {{ flight.durationMinutes | duration }} + + Nonstop +
    + +
    + + {{ flight.arrivalTime | date: 'HH:mm' }} + @if (dayOffset(flight) > 0) { + +{{ dayOffset(flight) }} + } + + {{ flight.destination.iataCode }} + {{ flight.destination.city }} +
    +
    + + +
    + + {{ flight.basePrice | currency: flight.currency : 'symbol' : '1.2-2' }} + + per traveller +
    + {{ cabinLabel(flight.cabinClass) }} + {{ flight.seatsAvailable }} left +
    + +
    + +
    {{ flight.departureTime | date: 'EEE d MMM y' }}
    +
    + } +
    + + @if (canPrev() || canNext()) { + + } +} diff --git a/frontend/src/app/features/search/search.scss b/frontend/src/app/features/search/search.scss new file mode 100644 index 0000000..f64920d --- /dev/null +++ b/frontend/src/app/features/search/search.scss @@ -0,0 +1,350 @@ +/* --- filter panel -------------------------------------------------------- */ + +.search-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 14px; + align-items: end; + padding: 18px 20px; + margin-bottom: 18px; +} + +.field-narrow { + max-width: 130px; +} + +.panel-actions { + display: flex; + gap: 8px; +} + +/* --- results bar --------------------------------------------------------- */ + +.results-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin: 0 2px 14px; + font-size: 0.9rem; +} + +.sort { + display: flex; + align-items: center; + gap: 8px; +} + +.sort select { + padding: 7px 11px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + font: inherit; + font-size: 0.88rem; + color: var(--text); +} + +.sort select:focus { + outline: none; + border-color: var(--brand-500); + box-shadow: 0 0 0 3px var(--brand-100); +} + +.skeleton-card { + padding: 20px; +} + +/* --- flight card --------------------------------------------------------- */ + +/* The date gets its own header row rather than floating over the card, so it + can never collide with the fare on the right. */ +.flight { + display: grid; + grid-template-columns: 210px 1fr 190px; + grid-template-areas: + 'date date date' + 'carrier itinerary fare'; + gap: 8px 20px; + align-items: center; + padding: 14px 20px 20px; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.carrier { + grid-area: carrier; +} +.itinerary { + grid-area: itinerary; +} +.fare { + grid-area: fare; +} +.departure-date { + grid-area: date; +} + +.flight:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow); +} + +/* carrier ---------------------------------------------------------------- */ + +.carrier { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.carrier-code { + display: grid; + place-items: center; + width: 42px; + height: 42px; + flex-shrink: 0; + border-radius: 10px; + background: var(--navy-900); + color: #fff; + font-weight: 700; + font-size: 0.88rem; + letter-spacing: 0.03em; +} + +.carrier-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.carrier-name { + font-weight: 600; + font-size: 0.92rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.carrier-meta { + font-size: 0.78rem; + color: var(--text-muted); +} + +/* itinerary --------------------------------------------------------------- */ + +.itinerary { + display: grid; + grid-template-columns: 1fr minmax(110px, 1.3fr) 1fr; + gap: 14px; + align-items: center; +} + +.endpoint { + display: flex; + flex-direction: column; + text-align: center; +} + +.endpoint .time { + font-size: 1.28rem; + font-weight: 650; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.next-day { + font-size: 0.62rem; + font-weight: 700; + color: var(--brand-600); + vertical-align: super; + margin-left: 1px; +} + +.endpoint .iata { + font-size: 0.84rem; + font-weight: 650; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +.endpoint .city { + font-size: 0.76rem; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.leg { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.leg-duration { + font-size: 0.79rem; + color: var(--text-muted); + font-weight: 550; +} + +/* The route line: a rule with a dot at each end and a plane at the tip. */ +.leg-line { + position: relative; + display: block; + width: 100%; + height: 1px; + background: var(--border-strong); +} + +.leg-line::before, +.leg-line::after { + content: ''; + position: absolute; + top: 50%; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--border-strong); + transform: translateY(-50%); +} + +.leg-line::before { + left: 0; +} + +.leg-line::after { + right: 0; +} + +.leg-line i { + position: absolute; + right: -2px; + top: 50%; + transform: translateY(-50%); + color: var(--brand-500); + font-size: 0.7rem; + font-style: normal; +} + +.leg-line i::before { + content: '➤'; +} + +.leg-stops { + font-size: 0.71rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--teal-600); + font-weight: 650; +} + +/* fare -------------------------------------------------------------------- */ + +.fare { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 5px; + text-align: right; + padding-left: 20px; + border-left: 1px solid var(--border); +} + +.price { + font-size: 1.42rem; + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.1; +} + +.price-meta { + font-size: 0.74rem; + color: var(--text-faint); +} + +.fare-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; + margin: 3px 0 5px; +} + +.fare-tags .badge { + text-transform: capitalize; +} + +.departure-date { + font-size: 0.75rem; + font-weight: 550; + text-align: right; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} + +/* --- pager --------------------------------------------------------------- */ + +.pager { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 20px; +} + +/* --- narrow screens ------------------------------------------------------ */ + +@media (max-width: 1040px) { + .flight { + grid-template-columns: 1fr; + grid-template-areas: + 'date' + 'carrier' + 'itinerary' + 'fare'; + gap: 16px; + } + + .fare { + flex-direction: row; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + text-align: left; + padding-left: 0; + padding-top: 16px; + border-left: none; + border-top: 1px solid var(--border); + } + + .fare-tags { + justify-content: flex-start; + margin: 0; + } + + .departure-date { + text-align: left; + } +} + +@media (max-width: 560px) { + .itinerary { + grid-template-columns: 1fr; + gap: 10px; + } + + .leg-line { + display: none; + } + + .endpoint { + flex-direction: row; + align-items: baseline; + gap: 8px; + text-align: left; + } +} diff --git a/frontend/src/app/features/search/search.spec.ts b/frontend/src/app/features/search/search.spec.ts new file mode 100644 index 0000000..2f7d0b8 --- /dev/null +++ b/frontend/src/app/features/search/search.spec.ts @@ -0,0 +1,264 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { Observable, of, throwError } from 'rxjs'; + +import { SearchPage } from './search'; +import { ApiService } from '../../core/api.service'; +import { Airport, Flight, FlightQuery, Page } from '../../core/models'; + +function airport(code: string, city: string): Airport { + return { id: `a-${code}`, iataCode: code, name: `${city} Airport`, city, country: 'X', timezone: 'UTC' }; +} + +function flight(overrides: Partial = {}): Flight { + return { + id: 'f-1', + flightNumber: 'AA100', + airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' }, + origin: airport('JFK', 'New York'), + destination: airport('LHR', 'London'), + 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', + ...overrides, + }; +} + +class FakeApi { + airports: Observable = of([airport('JFK', 'New York'), airport('LHR', 'London')]); + result: Observable> = of({ items: [flight()], total: 1, limit: 10, offset: 0 }); + queries: FlightQuery[] = []; + + listAirports = () => this.airports; + searchFlights = (q: FlightQuery) => { + this.queries.push(q); + return this.result; + }; +} + +describe('SearchPage', () => { + let fixture: ComponentFixture; + let page: SearchPage; + let api: FakeApi; + let navigations: Array<{ commands: unknown[]; extras?: unknown }>; + + function create(): void { + fixture = TestBed.createComponent(SearchPage); + page = fixture.componentInstance; + fixture.detectChanges(); + } + + beforeEach(() => { + TestBed.resetTestingModule(); + api = new FakeApi(); + navigations = []; + + TestBed.configureTestingModule({ + providers: [provideRouter([]), { provide: ApiService, useValue: api }], + }); + + const router = TestBed.inject(Router); + router.navigate = ((commands: unknown[], extras?: unknown) => { + navigations.push({ commands, extras }); + return Promise.resolve(true); + }) as Router['navigate']; + }); + + it('searches once on open and fills the airport pickers', () => { + create(); + + expect(api.queries.length).toBe(1); + expect(page.airports().length).toBe(2); + expect(page.flights().length).toBe(1); + expect(page.loading()).toBe(false); + }); + + it('asks for the first page with the default page size', () => { + create(); + + expect(api.queries[0].limit).toBe(10); + expect(api.queries[0].offset).toBe(0); + expect(api.queries[0].sort).toBe('price'); + }); + + it('passes the filter panel through to the API', () => { + create(); + page.form.setValue({ + origin: 'JFK', + destination: 'LHR', + date: '2026-09-19', + cabin: 'business', + passengers: 3, + sort: 'duration', + }); + + page.search(); + + const last = api.queries[api.queries.length - 1]; + expect(last.origin).toBe('JFK'); + expect(last.destination).toBe('LHR'); + expect(last.date).toBe('2026-09-19'); + expect(last.cabin).toBe('business'); + expect(last.passengers).toBe(3); + expect(last.sort).toBe('duration'); + }); + + it('returns to page one when the filters change', () => { + api.result = of({ items: [flight()], total: 40, limit: 10, offset: 0 }); + create(); + + page.nextPage(); + expect(page.offset()).toBe(10); + + page.search(); + expect(page.offset()).toBe(0); + }); + + it('clears the filters on reset', () => { + create(); + page.form.setValue({ + origin: 'JFK', + destination: 'LHR', + date: '2026-09-19', + cabin: 'first', + passengers: 5, + sort: 'departure', + }); + + page.reset(); + + expect(page.form.getRawValue()).toEqual({ + origin: '', + destination: '', + date: '', + cabin: '', + passengers: 1, + sort: 'price', + }); + }); + + describe('pagination', () => { + beforeEach(() => { + api.result = of({ items: [flight()], total: 25, limit: 10, offset: 0 }); + create(); + }); + + it('describes the visible slice', () => { + expect(page.showingFrom()).toBe(1); + expect(page.showingTo()).toBe(10); + }); + + it('moves forward and back', () => { + expect(page.canPrev()).toBe(false); + expect(page.canNext()).toBe(true); + + page.nextPage(); + expect(page.offset()).toBe(10); + expect(page.showingFrom()).toBe(11); + expect(page.canPrev()).toBe(true); + + page.nextPage(); + expect(page.offset()).toBe(20); + // 25 results: the last page is partial and there is no next. + expect(page.showingTo()).toBe(25); + expect(page.canNext()).toBe(false); + + page.prevPage(); + expect(page.offset()).toBe(10); + }); + + it('never steps past the ends', () => { + page.prevPage(); + expect(page.offset()).toBe(0); + + page.nextPage(); + page.nextPage(); + page.nextPage(); + expect(page.offset()).toBe(20); + }); + }); + + it('reports an empty result set rather than an error', () => { + api.result = of({ items: [], total: 0, limit: 10, offset: 0 }); + create(); + + expect(page.flights()).toEqual([]); + expect(page.showingFrom()).toBe(0); + expect(page.error()).toBeNull(); + expect(fixture.nativeElement.querySelector('.empty')).toBeTruthy(); + }); + + it('surfaces a failed search', () => { + api.result = throwError(() => new Error('network')); + create(); + + expect(page.error()).toContain('Could not load flights'); + expect(page.flights()).toEqual([]); + expect(page.loading()).toBe(false); + }); + + it('carries the traveller count into checkout', () => { + create(); + page.form.controls.passengers.setValue(4); + + page.select(flight({ id: 'f-99' })); + + expect(navigations).toEqual([ + { commands: ['/book', 'f-99'], extras: { queryParams: { passengers: 4 } } }, + ]); + }); + + describe('presentation helpers', () => { + beforeEach(() => create()); + + it('marks an arrival on a later calendar day', () => { + // Compare local calendar days, which is what the badge means. + const depart = new Date(2026, 8, 19, 22, 0); + const arrive = new Date(2026, 8, 20, 6, 0); + const overnight = flight({ + departureTime: depart.toISOString(), + arrivalTime: arrive.toISOString(), + }); + expect(page.dayOffset(overnight)).toBe(1); + }); + + it('shows no offset for a same-day arrival', () => { + const depart = new Date(2026, 8, 19, 8, 0); + const arrive = new Date(2026, 8, 19, 15, 0); + const sameDay = flight({ + departureTime: depart.toISOString(), + arrivalTime: arrive.toISOString(), + }); + expect(page.dayOffset(sameDay)).toBe(0); + }); + + it('flags scarce inventory as urgent', () => { + expect(page.seatTone(flight({ seatsAvailable: 3 }))).toBe('badge-warn'); + expect(page.seatTone(flight({ seatsAvailable: 5 }))).toBe('badge-warn'); + expect(page.seatTone(flight({ seatsAvailable: 6 }))).toBe('badge-neutral'); + }); + + it('renders cabin codes as words', () => { + expect(page.cabinLabel('premium_economy')).toBe('premium economy'); + expect(page.cabinLabel('economy')).toBe('economy'); + }); + }); + + it('renders a result card with the fare and route', () => { + create(); + + const card = fixture.nativeElement.querySelector('.flight'); + expect(card).toBeTruthy(); + expect(card.textContent).toContain('AA100'); + expect(card.textContent).toContain('JFK'); + expect(card.textContent).toContain('LHR'); + expect(card.textContent).toContain('165 left'); + }); +}); diff --git a/frontend/src/app/features/search/search.ts b/frontend/src/app/features/search/search.ts new file mode 100644 index 0000000..5d7fd91 --- /dev/null +++ b/frontend/src/app/features/search/search.ts @@ -0,0 +1,139 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { CurrencyPipe, DatePipe } from '@angular/common'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { ApiService } from '../../core/api.service'; +import { Airport, Flight, FlightQuery } from '../../core/models'; +import { DurationPipe } from '../../shared/duration.pipe'; + +const PAGE_SIZE = 10; + +/** Flight search: filter panel on top, fare results below. */ +@Component({ + selector: 'app-search', + imports: [ReactiveFormsModule, CurrencyPipe, DatePipe, DurationPipe], + templateUrl: './search.html', + styleUrl: './search.scss', +}) +export class SearchPage { + private readonly api = inject(ApiService); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + + readonly airports = signal([]); + readonly flights = signal([]); + readonly total = signal(0); + readonly offset = signal(0); + readonly loading = signal(true); + readonly error = signal(null); + + /** Today, as YYYY-MM-DD, so the date picker cannot select the past. */ + readonly today = new Date().toISOString().slice(0, 10); + + readonly form = this.fb.nonNullable.group({ + origin: [''], + destination: [''], + date: [''], + cabin: [''], + passengers: [1], + sort: ['price'], + }); + + readonly showingFrom = computed(() => (this.total() === 0 ? 0 : this.offset() + 1)); + readonly showingTo = computed(() => Math.min(this.offset() + PAGE_SIZE, this.total())); + readonly canPrev = computed(() => this.offset() > 0); + readonly canNext = computed(() => this.offset() + PAGE_SIZE < this.total()); + + constructor() { + this.api.listAirports().subscribe({ + next: (airports) => this.airports.set(airports), + error: () => this.airports.set([]), + }); + this.run(); + } + + /** Applies the filter panel, returning to the first page of results. */ + search(): void { + this.offset.set(0); + this.run(); + } + + reset(): void { + this.form.reset({ origin: '', destination: '', date: '', cabin: '', passengers: 1, sort: 'price' }); + this.search(); + } + + prevPage(): void { + if (this.canPrev()) { + this.offset.update((o) => Math.max(0, o - PAGE_SIZE)); + this.run(); + } + } + + nextPage(): void { + if (this.canNext()) { + this.offset.update((o) => o + PAGE_SIZE); + this.run(); + } + } + + select(flight: Flight): void { + void this.router.navigate(['/book', flight.id], { + queryParams: { passengers: this.form.getRawValue().passengers }, + }); + } + + /** + * Flights crossing midnight arrive on a later calendar day; airlines show + * that as a "+1" next to the arrival time and so do we. + */ + dayOffset(flight: Flight): number { + const depart = new Date(flight.departureTime); + const arrive = new Date(flight.arrivalTime); + const days = (d: Date) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000; + return days(arrive) - days(depart); + } + + /** Availability colour: scarce inventory should read as urgent. */ + seatTone(flight: Flight): string { + if (flight.seatsAvailable <= 5) { + return 'badge-warn'; + } + return 'badge-neutral'; + } + + cabinLabel(cabin: string): string { + return cabin.replace(/_/g, ' '); + } + + private run(): void { + this.loading.set(true); + this.error.set(null); + + const raw = this.form.getRawValue(); + const query: FlightQuery = { + origin: raw.origin, + destination: raw.destination, + date: raw.date, + cabin: raw.cabin as FlightQuery['cabin'], + passengers: raw.passengers, + sort: raw.sort as FlightQuery['sort'], + limit: PAGE_SIZE, + offset: this.offset(), + }; + + this.api.searchFlights(query).subscribe({ + next: (page) => { + this.flights.set(page.items); + this.total.set(page.total); + this.loading.set(false); + }, + error: () => { + this.error.set('Could not load flights. Please try again.'); + this.flights.set([]); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/layout/shell.html b/frontend/src/app/layout/shell.html new file mode 100644 index 0000000..f843b9a --- /dev/null +++ b/frontend/src/app/layout/shell.html @@ -0,0 +1,49 @@ +
    + + +
    +
    + + +
    + +
    + +
    +
    +
    diff --git a/frontend/src/app/layout/shell.scss b/frontend/src/app/layout/shell.scss new file mode 100644 index 0000000..9b90ba7 --- /dev/null +++ b/frontend/src/app/layout/shell.scss @@ -0,0 +1,225 @@ +.shell { + display: grid; + grid-template-columns: 258px 1fr; + min-height: 100vh; +} + +/* --- sidebar ------------------------------------------------------------- */ + +.sidebar { + display: flex; + flex-direction: column; + gap: 22px; + padding: 22px 18px; + background: var(--navy-900); + color: #d5e0ee; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 6px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 9px; + background: var(--brand-500); + color: #fff; + font-size: 1rem; +} + +.brand-name { + font-size: 1.08rem; + font-weight: 700; + letter-spacing: -0.02em; + color: #fff; +} + +.tenant-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 13px 14px; + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.tenant-label { + font-size: 0.68rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #8fa8c6; +} + +.tenant-name { + font-weight: 650; + color: #fff; + font-size: 0.98rem; +} + +.tenant-slug { + font-size: 0.76rem; + color: #8fa8c6; + font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; +} + +.nav { + display: flex; + flex-direction: column; + gap: 3px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 11px; + padding: 10px 13px; + border-radius: var(--radius-sm); + color: #cbd8e8; + font-weight: 550; + font-size: 0.94rem; + transition: background 0.15s ease; +} + +.nav-link:hover { + background: rgba(255, 255, 255, 0.08); + text-decoration: none; + color: #fff; +} + +.nav-link.active { + background: var(--brand-600); + color: #fff; +} + +.nav-icon { + width: 18px; + text-align: center; + font-size: 0.95rem; +} + +.sidebar-foot { + margin-top: auto; + padding: 0 6px; + font-size: 0.76rem; + line-height: 1.5; +} + +.sidebar-foot .faint { + color: #7f97b5; +} + +/* --- main ---------------------------------------------------------------- */ + +.main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 28px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.account { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} + +.avatar { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--brand-100); + color: var(--brand-600); + font-weight: 700; + font-size: 0.85rem; + flex-shrink: 0; +} + +.account-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.account-name { + font-weight: 600; + font-size: 0.92rem; + line-height: 1.3; +} + +.account-meta { + font-size: 0.78rem; + color: var(--text-muted); +} + +/* Only the role reads better title-cased -- an email address must not. */ +.role { + text-transform: capitalize; +} + +.content { + flex: 1; + padding: 28px; + max-width: 1280px; + width: 100%; +} + +/* --- narrow screens ------------------------------------------------------ */ + +@media (max-width: 860px) { + .shell { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: 14px; + padding: 14px 16px; + } + + .tenant-card { + flex-direction: row; + align-items: baseline; + gap: 8px; + padding: 7px 12px; + } + + .tenant-label, + .sidebar-foot { + display: none; + } + + .nav { + flex-direction: row; + flex-wrap: wrap; + margin-left: auto; + } + + .content { + padding: 18px 16px; + } + + .topbar { + padding: 12px 16px; + } +} diff --git a/frontend/src/app/layout/shell.spec.ts b/frontend/src/app/layout/shell.spec.ts new file mode 100644 index 0000000..78e5016 --- /dev/null +++ b/frontend/src/app/layout/shell.spec.ts @@ -0,0 +1,114 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; + +import { Shell } from './shell'; +import { AuthService } from '../core/auth.service'; +import { 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', +}; + +describe('Shell', () => { + let fixture: ComponentFixture; + let shell: Shell; + let signedOut: boolean; + let navigatedTo: unknown[][]; + + beforeEach(() => { + TestBed.resetTestingModule(); + signedOut = false; + navigatedTo = []; + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: AuthService, + useValue: { + tenant: () => tenant, + user: () => user, + signOut: () => { + signedOut = true; + }, + }, + }, + ], + }); + + const router = TestBed.inject(Router); + router.navigate = ((commands: unknown[]) => { + navigatedTo.push(commands); + return Promise.resolve(true); + }) as Router['navigate']; + + fixture = TestBed.createComponent(Shell); + shell = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('keeps the agency permanently on screen', () => { + const sidebar = fixture.nativeElement.querySelector('.tenant-card'); + + expect(sidebar.textContent).toContain('Skyline Travel'); + expect(sidebar.textContent).toContain('skyline'); + }); + + it('shows who is signed in', () => { + const account = fixture.nativeElement.querySelector('.account'); + + expect(account.textContent).toContain('Marcus Reid'); + expect(account.textContent).toContain('agent@skyline.test'); + }); + + describe('initials', () => { + it('takes the first letter of the first two names', () => { + expect(shell.initials('Marcus Reid')).toBe('MR'); + expect(shell.initials('Ada Lovelace Byron')).toBe('AL'); + }); + + it('copes with a single name', () => { + expect(shell.initials('Prince')).toBe('P'); + }); + + it('copes with extra spacing', () => { + expect(shell.initials(' Priya Nair ')).toBe('PN'); + }); + + it('falls back when there is no name yet', () => { + expect(shell.initials(undefined)).toBe('?'); + expect(shell.initials('')).toBe('?'); + }); + }); + + it('offers the three main destinations', () => { + const links: NodeListOf = + fixture.nativeElement.querySelectorAll('.nav-link'); + + expect(Array.from(links).map((a) => a.getAttribute('href'))).toEqual([ + '/dashboard', + '/search', + '/bookings', + ]); + }); + + it('signs out and returns to the sign-in screen', () => { + shell.signOut(); + + expect(signedOut).toBe(true); + expect(navigatedTo).toEqual([['/login']]); + }); +}); diff --git a/frontend/src/app/layout/shell.ts b/frontend/src/app/layout/shell.ts new file mode 100644 index 0000000..b4140aa --- /dev/null +++ b/frontend/src/app/layout/shell.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +import { AuthService } from '../core/auth.service'; + +/** + * The signed-in chrome: tenant-branded sidebar, account bar, routed content. + * Which agency you are working in is shown permanently in the sidebar, because + * in a multi-tenant console that is the single most important piece of context + * on screen. + */ +@Component({ + selector: 'app-shell', + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './shell.html', + styleUrl: './shell.scss', +}) +export class Shell { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + + readonly tenant = this.auth.tenant; + readonly user = this.auth.user; + + /** Initials for the avatar chip, e.g. "Marcus Reid" -> "MR". */ + initials(name: string | undefined): string { + if (!name) { + return '?'; + } + return name + .split(' ') + .filter((part) => part.length > 0) + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join(''); + } + + signOut(): void { + this.auth.signOut(); + void this.router.navigate(['/login']); + } +} diff --git a/frontend/src/app/shared/duration.pipe.spec.ts b/frontend/src/app/shared/duration.pipe.spec.ts new file mode 100644 index 0000000..ed03fc5 --- /dev/null +++ b/frontend/src/app/shared/duration.pipe.spec.ts @@ -0,0 +1,26 @@ +import { DurationPipe } from './duration.pipe'; + +describe('DurationPipe', () => { + const pipe = new DurationPipe(); + + it('renders hours and zero-padded minutes', () => { + expect(pipe.transform(445)).toBe('7h 25m'); + expect(pipe.transform(420)).toBe('7h 00m'); + expect(pipe.transform(65)).toBe('1h 05m'); + }); + + it('drops the hour part under an hour', () => { + expect(pipe.transform(45)).toBe('45m'); + expect(pipe.transform(0)).toBe('0m'); + }); + + it('handles long-haul durations past a day', () => { + expect(pipe.transform(1500)).toBe('25h 00m'); + }); + + it('renders a placeholder for missing or nonsensical values', () => { + expect(pipe.transform(null)).toBe('--'); + expect(pipe.transform(undefined)).toBe('--'); + expect(pipe.transform(-5)).toBe('--'); + }); +}); diff --git a/frontend/src/app/shared/duration.pipe.ts b/frontend/src/app/shared/duration.pipe.ts new file mode 100644 index 0000000..f89cc72 --- /dev/null +++ b/frontend/src/app/shared/duration.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** Renders a flight duration in minutes as "7h 25m" (or "45m" under an hour). */ +@Pipe({ name: 'duration' }) +export class DurationPipe implements PipeTransform { + transform(minutes: number | null | undefined): string { + if (minutes === null || minutes === undefined || minutes < 0) { + return '--'; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours === 0) { + return `${mins}m`; + } + return `${hours}h ${String(mins).padStart(2, '0')}m`; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..beeb89c --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,20 @@ + + + + + TravelHub + + + + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..8dad988 --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,400 @@ +/* TravelHub design system --------------------------------------------------- + One place for colour, type, elevation and the handful of primitives (button, + card, field, badge) that every screen reuses. Feature styles stay thin + because of it. */ + +:root { + /* Brand: a deep navy for chrome, a confident blue for actions, teal for + positive money/availability signals. */ + --navy-900: #0b1727; + --navy-800: #13263d; + --navy-700: #24405f; + + --brand-600: #0b5cd6; + --brand-500: #1470f0; + --brand-100: #e3edfd; + --brand-50: #f2f7ff; + + --teal-600: #00897b; + --teal-100: #d8f3ef; + + --amber-600: #b45309; + --amber-100: #fef3c7; + + --rose-600: #be123c; + --rose-100: #ffe4e9; + + --text: #0f172a; + --text-muted: #5b6b82; + --text-faint: #8496ad; + + --surface: #ffffff; + --surface-sunken: #f4f7fb; + --border: #e3e9f2; + --border-strong: #cfd9e8; + + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 18px; + + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow: 0 4px 16px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 18px 48px rgba(11, 23, 39, 0.16); + + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: var(--font); + font-size: 15px; + line-height: 1.55; + color: var(--text); + background: var(--surface-sunken); + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +h4 { + margin: 0; + line-height: 1.25; + letter-spacing: -0.015em; + font-weight: 650; +} + +h1 { + font-size: 1.65rem; +} +h2 { + font-size: 1.2rem; +} +h3 { + font-size: 1rem; +} + +p { + margin: 0; +} + +a { + color: var(--brand-600); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +/* --- layout helpers ------------------------------------------------------ */ + +.page-head { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 20px; +} + +.page-head .subtitle { + color: var(--text-muted); + font-size: 0.93rem; + margin-top: 4px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.muted { + color: var(--text-muted); +} +.faint { + color: var(--text-faint); +} + +/* --- card ---------------------------------------------------------------- */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.card-pad { + padding: 20px; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +/* --- buttons ------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font: inherit; + font-weight: 600; + font-size: 0.92rem; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + transform 0.06s ease; + white-space: nowrap; +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + background: var(--brand-600); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: #0a4fb8; +} + +.btn-ghost { + background: var(--surface); + border-color: var(--border-strong); + color: var(--text); +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-sunken); +} + +.btn-danger { + background: var(--surface); + border-color: #f2c2cd; + color: var(--rose-600); +} +.btn-danger:hover:not(:disabled) { + background: var(--rose-100); +} + +.btn-sm { + padding: 7px 13px; + font-size: 0.85rem; +} + +/* --- forms --------------------------------------------------------------- */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.field input, +.field select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font: inherit; + font-size: 0.95rem; + color: var(--text); + background: var(--surface); + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: var(--brand-500); + box-shadow: 0 0 0 3px var(--brand-100); +} + +.field input.invalid, +.field select.invalid { + border-color: #e2879a; +} + +.field .hint { + font-size: 0.78rem; + color: var(--text-faint); +} + +.field .error { + font-size: 0.78rem; + color: var(--rose-600); +} + +/* --- badges -------------------------------------------------------------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.02em; +} + +.badge-confirmed { + background: var(--teal-100); + color: var(--teal-600); +} +.badge-cancelled { + background: var(--rose-100); + color: var(--rose-600); +} +.badge-neutral { + background: var(--surface-sunken); + color: var(--text-muted); +} +.badge-brand { + background: var(--brand-100); + color: var(--brand-600); +} +.badge-warn { + background: var(--amber-100); + color: var(--amber-600); +} + +/* --- feedback ------------------------------------------------------------ */ + +.alert { + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 0.9rem; + border: 1px solid transparent; +} + +.alert-error { + background: var(--rose-100); + border-color: #f4c4cf; + color: #8c1030; +} + +.alert-info { + background: var(--brand-50); + border-color: var(--brand-100); + color: var(--brand-600); +} + +.empty { + padding: 44px 20px; + text-align: center; + color: var(--text-muted); +} + +.empty h3 { + margin-bottom: 6px; + color: var(--text); +} + +/* --- skeleton loading ---------------------------------------------------- */ + +.skeleton { + border-radius: var(--radius-sm); + background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%); + background-size: 400% 100%; + animation: shimmer 1.3s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } + * { + transition: none !important; + } +} + +/* --- tables -------------------------------------------------------------- */ + +.table-wrap { + overflow-x: auto; +} + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +table.data th { + text-align: left; + font-size: 0.74rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-faint); + font-weight: 650; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +table.data td { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +table.data tbody tr:last-child td { + border-bottom: none; +} + +table.data tbody tr:hover { + background: var(--brand-50); +} + +/* --- number formatting --------------------------------------------------- */ + +.tabular { + font-variant-numeric: tabular-nums; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..cb151e1 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..9c8efb9 --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/instruction.txt b/instruction.txt new file mode 100644 index 0000000..e69de29 diff --git a/memory.md b/memory.md new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0324e7 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "travelhub", + "version": "0.0.0", + "private": true, + "description": "Multi-tenant flight booking sample: Angular, Go, PostgreSQL.", + "scripts": { + "test": "npm run test:go && npm run test:web && npm run test:e2e", + "test:e2e": "cd e2e && npx playwright test", + "test:e2e:headed": "cd e2e && npx playwright test --headed", + "test:e2e:report": "cd e2e && npx playwright show-report", + "test:e2e:ui": "cd e2e && npx playwright test --ui", + "test:go": "cd backend && go test ./...", + "test:go:cover": "cd backend && go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out", + "test:go:race": "cd backend && go test -race ./...", + "test:go:verbose": "cd backend && go test -v ./...", + "test:unit": "npm run test:go && npm run test:web", + "test:web": "cd frontend && npx ng test --no-watch", + "test:web:cover": "cd frontend && npx ng test --no-watch --coverage", + "test:web:watch": "cd frontend && npx ng test", + "report": "node scripts/open-report.mjs", + "report:pdf": "cd e2e && node scripts/report-pdf.mjs", + "report:pdf:summary": "cd e2e && node scripts/report-pdf.mjs --summary", + "report:playwright": "node scripts/open-report.mjs playwright", + "report:serve": "node scripts/serve-report.mjs", + "lint": "npm run lint:go && npm run lint:e2e", + "lint:e2e": "cd e2e && npx tsc --noEmit", + "lint:go": "cd backend && go vet ./...", + "docs:tests": "node scripts/test-catalog.mjs", + "docs:tests:check": "node scripts/test-catalog.mjs --check", + "build": "npm run build:go && npm run build:web", + "build:go": "cd backend && go build -o bin/api ./cmd/api", + "build:web": "cd frontend && npx ng build", + "setup": "npm run setup:web && npm run setup:e2e", + "setup:e2e": "cd e2e && npm install && npx playwright install chromium", + "setup:web": "cd frontend && npm install", + "db:create": "node scripts/db.mjs create", + "db:migrate": "node scripts/db.mjs migrate", + "db:reset": "node scripts/db.mjs reset", + "db:test": "node scripts/db.mjs test-db", + "start:api": "cd backend && go run ./cmd/api", + "start:web": "cd frontend && npm start" + } +} diff --git a/scripts/db.mjs b/scripts/db.mjs new file mode 100644 index 0000000..14e77c6 --- /dev/null +++ b/scripts/db.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * Database chores for TravelHub, driven through psql. + * + * Credentials come from backend/.env (or the real environment, which wins), so + * this works the same on a developer machine and in CI. + * + * node scripts/db.mjs create create the application database + * node scripts/db.mjs migrate apply every migration in order + * node scripts/db.mjs reset drop, recreate and migrate (destructive) + * node scripts/db.mjs test-db create the empty database the Go tests use + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const MIGRATIONS = join(ROOT, 'backend', 'migrations'); + +// --- configuration --------------------------------------------------------- + +/** Reads backend/.env without overriding anything already in the environment. */ +function loadDotEnv() { + const path = join(ROOT, 'backend', '.env'); + if (!existsSync(path)) { + return; + } + for (const line of readFileSync(path, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq === -1) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if (value.length >= 2 && /^["'].*["']$/.test(value)) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadDotEnv(); + +const CONFIG = { + host: process.env.DB_HOST ?? 'localhost', + port: process.env.DB_PORT ?? '5432', + user: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'travelhub', + testDatabase: process.env.TEST_DB_NAME ?? 'travelhub_test', +}; + +/** + * Finds psql. It is routinely absent from PATH on Windows, so fall back to the + * standard install location before giving up. + */ +function findPsql() { + if (process.env.PSQL) { + return process.env.PSQL; + } + try { + execFileSync('psql', ['--version'], { stdio: 'ignore' }); + return 'psql'; + } catch { + // Not on PATH; try the usual Windows install root, newest version first. + const base = 'C:\\Program Files\\PostgreSQL'; + if (existsSync(base)) { + const versions = readdirSync(base).sort((a, b) => Number(b) - Number(a)); + for (const version of versions) { + const candidate = join(base, version, 'bin', 'psql.exe'); + if (existsSync(candidate)) { + return candidate; + } + } + } + } + throw new Error( + 'psql was not found. Add it to PATH, or set PSQL to its full path.\n' + + 'On Windows it usually lives in C:\\Program Files\\PostgreSQL\\17\\bin', + ); +} + +const PSQL = findPsql(); + +// --- psql helpers ---------------------------------------------------------- + +function run(args, { database = 'postgres', capture = false } = {}) { + return execFileSync( + PSQL, + ['-U', CONFIG.user, '-h', CONFIG.host, '-p', CONFIG.port, '-d', database, ...args], + { + env: { ...process.env, PGPASSWORD: CONFIG.password }, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }, + ); +} + +function databaseExists(name) { + const out = run(['-tAc', `SELECT 1 FROM pg_database WHERE datname='${name}'`], { capture: true }); + return out.trim() === '1'; +} + +function createDatabase(name) { + if (databaseExists(name)) { + console.log(`· ${name} already exists`); + return false; + } + run(['-c', `CREATE DATABASE "${name}"`], { capture: true }); + console.log(`✓ created ${name}`); + return true; +} + +function dropDatabase(name) { + if (!databaseExists(name)) { + return; + } + // Existing sessions would block the drop, so close them first. + run( + [ + '-c', + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname='${name}' AND pid <> pg_backend_pid()`, + ], + { capture: true }, + ); + run(['-c', `DROP DATABASE "${name}"`], { capture: true }); + console.log(`✓ dropped ${name}`); +} + +function migrate(name) { + const files = readdirSync(MIGRATIONS) + .filter((f) => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + process.stdout.write(` applying ${file} ... `); + run(['-v', 'ON_ERROR_STOP=1', '-q', '-f', join(MIGRATIONS, file)], { database: name }); + console.log('ok'); + } + console.log(`✓ ${name} is up to date (${files.length} migrations)`); +} + +// --- commands -------------------------------------------------------------- + +const commands = { + create() { + createDatabase(CONFIG.database); + }, + + migrate() { + createDatabase(CONFIG.database); + migrate(CONFIG.database); + }, + + reset() { + console.log(`Resetting ${CONFIG.database}. All data in it will be lost.`); + dropDatabase(CONFIG.database); + createDatabase(CONFIG.database); + migrate(CONFIG.database); + }, + + 'test-db'() { + // The Go tests apply the schema themselves; they only need the database. + createDatabase(CONFIG.testDatabase); + console.log(`✓ ${CONFIG.testDatabase} ready for go test ./...`); + }, +}; + +const command = process.argv[2]; +if (!command || !(command in commands)) { + console.error(`Usage: node scripts/db.mjs <${Object.keys(commands).join('|')}>`); + process.exit(1); +} + +if (!CONFIG.password) { + console.error('No database password. Set DB_PASSWORD, or fill in backend/.env.'); + process.exit(1); +} + +try { + commands[command](); +} catch (err) { + console.error(`\n✗ ${command} failed: ${err.message}`); + process.exit(1); +} diff --git a/scripts/open-report.mjs b/scripts/open-report.mjs new file mode 100644 index 0000000..35d9648 --- /dev/null +++ b/scripts/open-report.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Opens a generated report in the default browser. + * + * node scripts/open-report.mjs the evidence report + * node scripts/open-report.mjs playwright Playwright's own HTML report + */ + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const which = process.argv[2] ?? 'evidence'; +const target = + which === 'playwright' + ? join(ROOT, 'e2e', 'playwright-report', 'index.html') + : join(ROOT, 'e2e', 'evidence-report', 'index.html'); + +if (!existsSync(target)) { + console.error(`No report at ${target}\nRun the suite first: npm run test:e2e`); + process.exit(1); +} + +// Each platform has its own opener; none of them need a dependency. +const [command, args] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', target]] + : process.platform === 'darwin' + ? ['open', [target]] + : ['xdg-open', [target]]; + +spawn(command, args, { detached: true, stdio: 'ignore' }).unref(); +console.log(`Opened ${target}`); diff --git a/scripts/serve-report.mjs b/scripts/serve-report.mjs new file mode 100644 index 0000000..cb219b9 --- /dev/null +++ b/scripts/serve-report.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Serves the evidence report over HTTP. + * + * The report is a plain folder of files and opens perfectly well from disk, but + * a localhost URL is easier to share with a colleague on the same machine, and + * some browsers treat file:// pages more suspiciously than http://. + * + * node scripts/serve-report.mjs http://localhost:9400 + * node scripts/serve-report.mjs --port 8123 + * node scripts/serve-report.mjs --no-open + */ + +import { createServer } from 'node:http'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { dirname, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DIR = join(ROOT, 'e2e', 'evidence-report'); + +const args = process.argv.slice(2); +const portFlag = args.indexOf('--port'); +const PORT = portFlag !== -1 ? Number(args[portFlag + 1]) : 9400; +const shouldOpen = !args.includes('--no-open'); + +if (!existsSync(join(DIR, 'index.html'))) { + console.error(`No report at ${DIR}\nRun the suite first: npm run test:e2e`); + process.exit(1); +} + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.pdf': 'application/pdf', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +const server = createServer((req, res) => { + const requested = decodeURIComponent((req.url ?? '/').split('?')[0]); + const relative = requested === '/' ? 'index.html' : requested.replace(/^\/+/, ''); + + // normalize collapses any ".." before it can escape the report directory. + const target = join(DIR, normalize(relative)); + if (!target.startsWith(DIR)) { + res.writeHead(403).end('Forbidden'); + return; + } + + if (!existsSync(target) || statSync(target).isDirectory()) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(`Not found: ${relative}\n\nThis server only holds the evidence report.`); + return; + } + + res.writeHead(200, { + 'Content-Type': TYPES[extname(target).toLowerCase()] ?? 'application/octet-stream', + 'Cache-Control': 'no-store', + }); + createReadStream(target).pipe(res); +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error( + `Port ${PORT} is already in use.\n` + + `Try another: npm run report:serve -- --port ${PORT + 1}`, + ); + process.exit(1); + } + throw err; +}); + +server.listen(PORT, () => { + const url = `http://localhost:${PORT}/`; + console.log( + [ + '', + ` Test report: ${url}`, + ` PDF: ${url}TravelHub-test-report.pdf`, + ` Markdown: ${url}report.md`, + '', + ' Press Ctrl+C to stop.', + '', + ].join('\n'), + ); + + if (shouldOpen) { + const [cmd, cmdArgs] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : process.platform === 'darwin' + ? ['open', [url]] + : ['xdg-open', [url]]; + spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' }).unref(); + } +}); diff --git a/scripts/test-catalog.mjs b/scripts/test-catalog.mjs new file mode 100644 index 0000000..a2df96b --- /dev/null +++ b/scripts/test-catalog.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +/** + * Generates TEST-CATALOG.md by reading the test files themselves. + * + * Static parsing rather than running the suites: the catalogue can then be + * regenerated in a second, with no database and no browser, which is the only + * way a document like this stays current. + * + * node scripts/test-catalog.mjs write TEST-CATALOG.md + * node scripts/test-catalog.mjs --check fail if it is out of date (CI) + * node scripts/test-catalog.mjs --stdout print instead of writing + */ + +import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = join(ROOT, 'TEST-CATALOG.md'); + +// --- file discovery -------------------------------------------------------- + +function walk(dir, match, found = []) { + if (!existsSync(dir)) { + return found; + } + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) { + continue; + } + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full, match, found); + } else if (match(entry)) { + found.push(full); + } + } + return found; +} + +const posix = (p) => relative(ROOT, p).split(sep).join('/'); + +// --- Go --------------------------------------------------------------------- + +/** Turns TestCreateBookingSellsSeats into "Create booking sells seats". */ +function humanizeGo(name) { + const body = name.replace(/^Test/, ''); + const words = body + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function parseGo(file) { + const src = readFileSync(file, 'utf8'); + const lines = src.split('\n'); + + const tests = []; + let current = null; + let buffer = []; + + const flush = () => { + if (!current) { + return; + } + current.subtests = subtestsFrom(buffer.join('\n')); + tests.push(current); + current = null; + buffer = []; + }; + + for (const line of lines) { + const start = /^func (Test\w+)\(t \*testing\.T\)/.exec(line); + if (start) { + flush(); + current = { name: start[1], label: humanizeGo(start[1]), subtests: [] }; + continue; + } + if (/^func /.test(line)) { + flush(); + continue; + } + if (current) { + buffer.push(line); + } + } + flush(); + + return tests; +} + +/** + * Pulls subtest names out of a function body. Covers the three shapes used + * here: a literal t.Run, a table with a `name:` field, and a positional table + * whose first column is the subtest name. + */ +function subtestsFrom(body) { + const names = new Set(); + + for (const m of body.matchAll(/t\.Run\(\s*"([^"]+)"/g)) { + names.add(m[1]); + } + for (const m of body.matchAll(/\bname:\s*"([^"]+)"/g)) { + names.add(m[1]); + } + // Positional table rows: {"price", "CHEAPSLOW"}, + const usesPositional = /t\.Run\(\s*tc\.(\w+)/.test(body) && !/\bname:/.test(body); + if (usesPositional) { + for (const m of body.matchAll(/^\s*\{"([^"]+)",/gm)) { + names.add(m[1]); + } + } + // A table of awkward values run by their own value, e.g. passwords. + for (const m of body.matchAll(/t\.Run\(\s*(\w+),\s*func/g)) { + void m; // the values are not statically knowable; counted as one case + } + + return [...names]; +} + +// --- TypeScript (Vitest and Playwright) ------------------------------------- + +/** + * Walks a spec file tracking describe nesting by brace depth, so each test is + * reported under the describes that contain it. + */ +function parseSpec(file) { + const src = readFileSync(file, 'utf8'); + const lines = src.split('\n'); + + const groups = []; + const stack = []; + let depth = 0; + + const DESCRIBE = /^\s*(?:test\.)?describe(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/; + const CASE = /^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/; + + for (const raw of lines) { + const line = raw.replace(/\/\/.*$/, ''); + + const describe = DESCRIBE.exec(line); + if (describe) { + stack.push({ title: describe[2], depth }); + } else { + const testCase = CASE.exec(line); + if (testCase) { + groups.push({ suite: stack.map((s) => s.title).join(' › '), title: testCase[2] }); + } + } + + depth += (line.match(/\{/g) ?? []).length; + depth -= (line.match(/\}/g) ?? []).length; + while (stack.length && depth <= stack[stack.length - 1].depth) { + stack.pop(); + } + } + + return groups; +} + +// --- rendering -------------------------------------------------------------- + +function goSection(files) { + const out = []; + let functions = 0; + let cases = 0; + + for (const file of files) { + const tests = parseGo(file); + if (tests.length === 0) { + continue; + } + const fileCases = tests.reduce((n, t) => n + Math.max(1, t.subtests.length), 0); + functions += tests.length; + cases += fileCases; + + out.push(`### \`${posix(file)}\``, ''); + out.push(`${tests.length} tests, ${fileCases} cases.`, ''); + for (const test of tests) { + out.push(`- **${test.label}** — \`${test.name}\``); + for (const sub of test.subtests) { + out.push(` - ${sub}`); + } + } + out.push(''); + } + + return { lines: out, functions, cases }; +} + +function specSection(files, label) { + const out = []; + let cases = 0; + + for (const file of files) { + const found = parseSpec(file); + if (found.length === 0) { + continue; + } + cases += found.length; + + out.push(`### \`${posix(file)}\``, ''); + out.push(`${found.length} ${found.length === 1 ? 'test' : 'tests'}.`, ''); + + let lastSuite = null; + for (const item of found) { + if (item.suite !== lastSuite) { + if (item.suite) { + out.push(`- **${item.suite}**`); + } + lastSuite = item.suite; + } + out.push(item.suite ? ` - ${item.title}` : `- ${item.title}`); + } + out.push(''); + } + + return { lines: out, cases, label }; +} + +// --- assemble --------------------------------------------------------------- + +const goFiles = walk(join(ROOT, 'backend'), (f) => f.endsWith('_test.go')).sort(); +const webFiles = walk(join(ROOT, 'frontend', 'src'), (f) => f.endsWith('.spec.ts')).sort(); +const e2eFiles = walk(join(ROOT, 'e2e', 'tests'), (f) => f.endsWith('.spec.ts')).sort(); + +const go = goSection(goFiles); +const web = specSection(webFiles, 'Angular'); +const e2e = specSection(e2eFiles, 'Playwright'); + +const total = go.cases + web.cases + e2e.cases; +const files = goFiles.length + webFiles.length + e2eFiles.length; + +// Specs that a second Playwright project re-runs under a mobile viewport. +const REPEATED_ON_MOBILE = /(auth|responsive)\.spec\.ts$/; +const mobileRepeats = e2eFiles + .filter((f) => REPEATED_ON_MOBILE.test(f)) + .reduce((n, f) => n + parseSpec(f).length, 0); + +const doc = [ + '# TravelHub test catalogue', + '', + '> Generated by `npm run docs:tests` from the test sources.', + '> Do not edit by hand — regenerate instead.', + '', + `**${total} test cases** across ${files} files.`, + '', + '| Suite | Files | Cases | Command |', + '| ----- | ----: | ----: | ------- |', + `| [Go](#go-backend) | ${goFiles.length} | ${go.cases} | \`npm run test:go\` |`, + `| [Angular](#angular-frontend) | ${webFiles.length} | ${web.cases} | \`npm run test:web\` |`, + `| [Playwright](#playwright-end-to-end) | ${e2eFiles.length} | ${e2e.cases} | \`npm run test:e2e\` |`, + `| **Total** | **${files}** | **${total}** | \`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 ${go.functions} 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\` (${mobileRepeats} tests) under a second,`, + ` mobile viewport, so a full run executes ${e2e.cases + mobileRepeats} tests from ${e2e.cases} 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)', + '', + `${go.functions} test functions covering ${go.cases} cases. These run against a real`, + 'PostgreSQL, with a fresh tenant created and dropped per test.', + '', + ...go.lines, + '---', + '', + '## Angular (frontend)', + '', + `${web.cases} tests. Vitest with jsdom, via \`ng test\`.`, + '', + ...web.lines, + '---', + '', + '## Playwright (end to end)', + '', + `${e2e.cases} tests, each run against a real browser and the real stack.`, + '', + ...e2e.lines, +].join('\n'); + +// --- output ----------------------------------------------------------------- + +if (process.argv.includes('--stdout')) { + process.stdout.write(doc); +} else if (process.argv.includes('--check')) { + const existing = existsSync(OUT) ? readFileSync(OUT, 'utf8') : ''; + if (existing.trim() !== doc.trim()) { + console.error('✗ TEST-CATALOG.md is out of date. Run: npm run docs:tests'); + process.exit(1); + } + console.log('✓ TEST-CATALOG.md is up to date'); +} else { + writeFileSync(OUT, doc + '\n', 'utf8'); + console.log(`✓ wrote ${posix(OUT)} — ${total} cases`); + console.log(` Go ${go.cases} · Angular ${web.cases} · Playwright ${e2e.cases}`); +} diff --git a/security/TravelHub-VAPT-report.pdf b/security/TravelHub-VAPT-report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..58416a4b39269086d704db5a794a18d034789d40 GIT binary patch literal 186938 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 literal 0 HcmV?d00001 diff --git a/security/VAPT-REPORT.md b/security/VAPT-REPORT.md new file mode 100644 index 0000000..e438e05 --- /dev/null +++ b/security/VAPT-REPORT.md @@ -0,0 +1,396 @@ +# TravelHub — Whitebox Vulnerability Assessment & Penetration Test + +**Engagement type:** Authorised whitebox (source-assisted) VAPT +**Target:** TravelHub multi-tenant flight-booking platform — Go API (`:8080`), Angular SPA (`:4200`), PostgreSQL 17 +**Scope:** `backend/`, `frontend/`, database, and running instances on localhost +**Method:** Source review of the full codebase plus dynamic testing against the running stack +**Date:** 12 September 2026 + +--- + +## 1. Executive summary + +TravelHub is architecturally sound on the issues that most often sink a +multi-tenant application. Tenant isolation holds under direct testing, SQL is +parameterised throughout, the JSON decoder rejects unknown fields, request +bodies are size-capped, and the Angular client escapes interpolated data by +default. The 311-test automated suite already guards the core business-logic +invariants (seat accounting, cross-tenant reads, overselling). + +The weaknesses found are **operational and defence-in-depth**, not broken access +control. The two that matter most are a **database superuser connection** — which +turns any application compromise into total database control and nullifies the +carefully built application-layer tenant isolation — and a **timing side-channel +that backfired**: the mitigation intended to hide which usernames exist actually +makes them *more* distinguishable. + +None of the findings is a remotely exploitable pre-authentication breach on the +current localhost deployment. Several become serious the moment this is exposed +beyond localhost, which the README states is the intended next step. + +### Findings at a glance + +| # | Severity | Finding | +| - | -------- | ------- | +| 1 | **High** | Application connects to PostgreSQL as a superuser (no least privilege) | +| 2 | **High** | Username enumeration via a timing oracle — the equalisation mitigation is miscalibrated and inverts | +| 3 | Medium | Bcrypt cost factor 6, below the OWASP-recommended minimum of 10 | +| 4 | Medium | No rate limiting or account lockout on authentication | +| 5 | Medium | Tenant enumeration via timing (unknown tenant short-circuits before password check) | +| 6 | Medium | No HTTP security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP…) | +| 7 | Medium | Database connections run in plaintext (`sslmode=disable`) | +| 8 | Low | Real database password committed in `config_test.go` | +| 9 | Low | Credentials held reversibly in `localStorage` and replayed on every request (inherent to Basic auth) | +| 10 | Low | API stores markup verbatim — no server-side output encoding (defence-in-depth only) | +| 11 | Info | `RequireAdmin` middleware is implemented but wired to no route; the role model is unenforced | + +--- + +## 2. Scope, method and rules of engagement + +This was a **whitebox** test: the assessor had the full source tree, the +database, and running instances. Testing combined: + +- **Static review** — grep-driven and manual reading of the auth middleware, + repository (SQL) layer, HTTP handlers, configuration loader, and Angular auth + service and interceptor. +- **Dynamic testing** — authenticated and unauthenticated requests against the + live API: SQL-injection probes, IDOR attempts, mass-assignment, brute-force + and timing measurement, stored-XSS sink analysis, payload-size and CORS tests. + +Destructive tests (e.g. `DROP TABLE` injection) were run against the +development database with immediate cleanup. No third-party or production +systems were touched. All demo data was restored to its seeded state on +completion. + +--- + +## 3. What was tested and found robust + +These were actively attacked and **held**; they are recorded so the report is a +fair account, not only a list of faults. + +| Control | Test | Result | +| ------- | ---- | ------ | +| SQL injection | `' OR '1'='1`, `'; DROP TABLE bookings;--`, `UNION SELECT` in `origin`/`destination` | Parameterised (`$1…$n`); payloads treated as literal IATA codes, `total=0` | +| Injection via `ORDER BY` | `sort=(SELECT 1)`, `sort=price;DROP` | `orderClause()` whitelists to three fixed clauses; junk falls back to price | +| Mass assignment | POST booking with `status`, `totalAmount`, `bookingReference`, `tenantId` | Rejected 400 — `DisallowUnknownFields()`; price/PNR/tenant are server-derived | +| Cross-tenant read (IDOR) | Fetch another agency's booking/flight by id | 404 `ErrNotFound`; every query filters `tenant_id` from the identity, not the request | +| Credential misuse across tenants | Skyline password against Globetrotter tenant | 401 | +| DoS via large body | 3 MB request body | 400 — `http.MaxBytesReader` caps at 1 MB | +| CORS | Reflect `Origin: https://evil.example.com` | Not reflected; only the configured allow-list origin receives ACAO + credentials | +| Error verbosity | Malformed UUID, malformed JSON | Generic messages; no stack traces or SQL leaked | +| Reflected XSS | Angular templating | No `innerHTML`/`bypassSecurityTrust`/`eval` anywhere; interpolation auto-escapes | + +--- + +## 4. Detailed findings + +### FINDING-1 — Application connects to PostgreSQL as a superuser · **HIGH** + +**Category:** CWE-250 Execution with Unnecessary Privileges · A04:2021 Insecure Design + +**Evidence.** The application connects with the credentials in `backend/.env` +(`DB_USER=postgres`). That role is the cluster superuser: + +``` + rolname | rolsuper | rolcreatedb | rolcreaterole | rolbypassrls +----------+----------+-------------+---------------+-------------- + postgres | t | t | t | t +``` + +**Impact.** Tenant isolation in TravelHub is enforced entirely in application +code — every repository query filters on `tenant_id`. That is a reasonable +design, but it means the database connection is the last line of defence, and +here it has *no* limits. Any flaw that yields query control (a future missed +`WHERE`, a dynamic-SQL regression, an ORM swap) is not a one-tenant leak but +full read/write/DDL over every tenant, plus `COPY … TO PROGRAM` command +execution and `rolbypassrls` defeating any future row-level security. The +superuser connection silently converts application bugs into total compromise. + +**Recommendation.** Create a least-privilege role the app connects as: + +```sql +CREATE ROLE travelapp LOGIN PASSWORD '…'; +GRANT CONNECT ON DATABASE travelhub TO travelapp; +GRANT USAGE ON SCHEMA public TO travelapp; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO travelapp; +-- deliberately NOT: SUPERUSER, CREATEROLE, CREATEDB, TRUNCATE, DDL +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO travelapp; +``` + +Keep migrations under a separate, higher-privilege role that the running +service never uses. + +--- + +### FINDING-2 — Username enumeration via an inverted timing oracle · **HIGH** + +**Category:** CWE-208 Observable Timing Discrepancy · CWE-203 Observable Discrepancy + +**Evidence.** `middleware.go` deliberately performs a bcrypt comparison against a +dummy hash when a user is not found, to equalise response time (a correct +instinct). But the dummy hash is **cost 10**: + +```go +var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") +``` + +while the real stored hashes are **cost 6** (`002_seed.sql` uses pgcrypto +`gen_salt('bf')`, whose default is 6). Because verifying a cost-10 hash costs +~16× a cost-6 hash, the "user does not exist" path is dramatically *slower* than +the "user exists, wrong password" path. Measured (mean of 30): + +``` +valid user + wrong password : 5.1 ms +unknown user, valid tenant : 47.3 ms ← ~9x slower +``` + +**Impact.** An unauthenticated attacker distinguishes valid from invalid +usernames with a single request each, at ~9× separation — a wide, reliable +margin needing no statistics. This enumerates the tenant's user directory +(valid email addresses) for targeted phishing or focused brute force. The +mitigation did not merely fail; it created a *stronger* oracle than doing +nothing, because the two branches now use different work factors. + +**Recommendation.** Make the dummy hash's cost identical to the real one, and +pin both. After raising the real cost to 12 (see FINDING-3), regenerate the +dummy at cost 12 so both branches do equal work. Better still, verify against a +per-deployment constant hash generated at startup with the exact configured +cost, so the two can never drift apart again. + +--- + +### FINDING-3 — Bcrypt cost factor below recommended minimum · **MEDIUM** + +**Category:** CWE-916 Use of Password Hash With Insufficient Computational Effort + +**Evidence.** All stored hashes carry the `$2a$06$` prefix — cost 6: + +``` +admin@skyline.test | $2a$06$ | 60 +agent@skyline.test | $2a$06$ | 60 +``` + +`002_seed.sql` relies on pgcrypto's `gen_salt('bf')` default (6). The test +helper `testsupport.go` uses `bcrypt.MinCost` (4) — acceptable for test speed, +but it means production hashing strength is never exercised by the suite. + +**Impact.** Cost 6 is 2⁴ = 16× weaker than the OWASP-recommended cost-10 floor +and 64× weaker than cost 12. Against an offline attacker who obtains the hashes +(see FINDING-1), it materially shortens the time to crack weak and medium +passwords. + +**Recommendation.** Hash at **cost 12** in application code rather than in SQL, +so the work factor lives with the app and is consistent. Hash on the Go side +during any user-creation flow (`bcrypt.GenerateFromPassword(pw, 12)`); reseed +demo users accordingly. Support transparent rehash-on-login when an old-cost +hash is encountered. + +--- + +### FINDING-4 — No rate limiting or account lockout on authentication · **MEDIUM** + +**Category:** CWE-307 Improper Restriction of Excessive Authentication Attempts · A07:2021 + +**Evidence.** 25 failed logins for a valid account completed in 144 ms, all +returning 401 — no `429`, no delay, no lockout: + +``` +25 failed logins in 144 ms -> 25x HTTP 401 +``` + +**Impact.** Online password brute force and credential stuffing are unthrottled. +Combined with FINDING-2 (which hands the attacker the valid usernames) and +FINDING-3 (weaker hashes), the account-takeover path is realistic once exposed +beyond localhost. + +**Recommendation.** Add per-source-IP and per-account throttling with +exponential backoff and temporary lockout after N failures. A middleware layer +(e.g. token-bucket keyed on IP + `X-Tenant` + email) fits the existing `Chain` +composition. Return `429` with `Retry-After`. Log repeated failures for +alerting. + +--- + +### FINDING-5 — Tenant enumeration via timing · **MEDIUM** + +**Category:** CWE-204 Observable Response Discrepancy + +**Evidence.** An unknown *tenant* short-circuits before any password check +(`TenantBySlug` returns `ErrNotFound`, and the handler returns before the bcrypt +call), so it is far faster than a known tenant with bad credentials: + +``` +unknown tenant : 0.8 ms +valid tenant, wrong pw : 5.1 ms +``` + +**Impact.** An attacker can discover which agency slugs exist. Lower impact than +FINDING-2 (the public `GET /api/tenants` endpoint already lists active tenants +by design), but it also reveals *suspended* tenants, which the public endpoint +deliberately hides. + +**Recommendation.** Perform the dummy bcrypt comparison on the unknown-tenant +path too, so all failure modes cost the same. Fold this into the FINDING-2 fix. + +--- + +### FINDING-6 — Missing HTTP security headers · **MEDIUM** + +**Category:** CWE-693 Protection Mechanism Failure · A05:2021 Security Misconfiguration + +**Evidence.** API responses carry only `Content-Type`, `Content-Length`, `Date`. +Absent: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, +`X-Frame-Options`/`frame-ancestors`, `Content-Security-Policy`, +`Referrer-Policy`, `Cache-Control` on authenticated JSON. The Angular +`index.html` ships no CSP either. + +**Impact.** No MIME-sniffing protection, no clickjacking defence, no CSP to +blunt an XSS should one ever be introduced, and browsers may cache +authenticated responses. Individually minor; collectively they remove several +cheap layers of defence. + +**Recommendation.** Add a `SecureHeaders` middleware to the `Chain`: +`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, +`Referrer-Policy: no-referrer`, `Cache-Control: no-store` on authenticated +responses, and `Strict-Transport-Security` once TLS is terminated. Serve the SPA +with a restrictive `Content-Security-Policy`. + +--- + +### FINDING-7 — Database connections run in plaintext · **MEDIUM** + +**Category:** CWE-319 Cleartext Transmission of Sensitive Information + +**Evidence.** `backend/.env` and `.env.example` set `DB_SSLMODE=disable`; the +live backend connection reports `PLAINTEXT` via `pg_stat_ssl`. + +**Impact.** On localhost, negligible. Once the database is on a separate host or +managed service, credentials and every row (PII: passenger names, passport +numbers, contact details) cross the network unencrypted, exposed to anyone on +the path. + +**Recommendation.** Use `sslmode=verify-full` with a pinned CA for any non-local +database. Keep `disable` only for the loopback dev setup, and document that the +production value must differ. + +--- + +### FINDING-8 — Real database password committed in test source · **LOW** + +**Category:** CWE-798 Use of Hard-coded Credentials + +**Evidence.** `backend/internal/config/config_test.go` contains the actual +development database password (redacted here; it was the live value from +`backend/.env`) as a test literal for the +DSN-escaping test. + +**Impact.** Low today (dev password, not yet a git repository), but it will be +committed on first `git init`/push and then lives in history forever, including +if this password is reused elsewhere. Secrets in test fixtures are a common +real-world leak vector. + +**Recommendation.** Replace with a synthetic password that still exercises the +escaping logic (e.g. `p@ss:w/rd?#%` — the test already covers such cases). Never +place a real, in-use secret in tracked source. Add a secret-scanning pre-commit +hook (e.g. gitleaks) before the first commit. + +--- + +### FINDING-9 — Credentials stored reversibly client-side and replayed · **LOW** + +**Category:** CWE-522 Insufficiently Protected Credentials · CWE-312 Cleartext Storage + +**Evidence.** `auth.service.ts` stores `btoa(email:password)` in `localStorage` +and the interceptor attaches it to every API call. base64 is encoding, not +encryption — the plaintext password is trivially recoverable from the browser. + +**Impact.** This is inherent to HTTP Basic auth, and the README already flags it. +The concrete risk: any XSS (or a malicious/compromised dependency in the SPA) +reads the reusable password straight out of `localStorage` — worse than a +short-lived token would be — and the credential travels on every single request. + +**Recommendation.** As the README foresees, move to short-lived tokens (JWT or +opaque session) with refresh, delivered in an `HttpOnly`, `Secure`, `SameSite` +cookie so client script cannot read them. The auth logic is already isolated to +`internal/middleware` + `internal/auth` and the Angular `auth.service`, so the +blast radius of the change is small. + +--- + +### FINDING-10 — No server-side output encoding of stored data · **LOW** + +**Category:** CWE-79 (stored-XSS sink, currently unreachable) + +**Evidence.** A passenger created with `firstName = +` is stored and returned verbatim by the +API. + +**Impact.** **Not currently exploitable** — every consumer is the Angular SPA, +which output-encodes on interpolation, and no `innerHTML` sink exists. The risk +is latent: a future consumer that renders this data as HTML (an email +confirmation, a PDF ticket, an admin tool, a report export) would execute it. It +is recorded as defence-in-depth. + +**Recommendation.** Treat the API as the trust boundary: validate/normalise +free-text fields on input (reject or strip control characters and markup where a +name field cannot legitimately contain them), and ensure any non-Angular +renderer of this data encodes for its context. Do not rely solely on the +current client escaping. + +--- + +### FINDING-11 — Role model implemented but unenforced · **INFO** + +**Category:** CWE-1220 Insufficient Granularity of Access Control (latent) + +**Evidence.** `RequireAdmin` middleware exists and is unit-tested, and users +carry an `admin`/`agent` role, but `router.go` gates **no** route with it — every +authenticated user reaches every endpoint. + +**Impact.** None today: there is no admin-only functionality to protect, so +`agent` and `admin` are functionally identical. Flagged because the moment an +admin-only capability is added (tenant settings, user management, refunds), it +must be remembered to attach `RequireAdmin`, or it will be silently open to all +agents. This is a broken-access-control regression waiting to happen. + +**Recommendation.** When admin functionality is introduced, gate it explicitly +and add a test asserting an `agent` receives 403 — mirroring the existing +`TestRequireAdmin`. Consider a default-deny posture where new routes are +explicitly assigned a required role. + +--- + +## 5. Remediation priority + +| Priority | Findings | Rationale | +| -------- | -------- | --------- | +| **Do before any non-local deployment** | 1, 2, 4, 6, 7 | These convert from low-risk to serious the instant the app leaves localhost | +| **Do soon** | 3, 5, 8 | Cheap fixes that raise the cost of the attack paths above | +| **Do as the design evolves** | 9, 10, 11 | Tie to the planned auth change and to new features | + +A single focused change set closes most of the high/medium items: introduce a +least-privilege DB role (1), add `SecureHeaders` + rate-limiting middleware to +the existing `Chain` (4, 6), align bcrypt cost and the dummy hash and move +hashing into Go (2, 3, 5), and flip `sslmode` for non-local (7). + +--- + +## 6. Methodology notes & limitations + +- Testing was against a single-node localhost deployment with seeded data. A + production deployment behind a reverse proxy / WAF / TLS terminator may + already mitigate some network-layer findings (6, 7) — verify against the real + topology. +- Timing measurements were taken over loopback (mean of 30 requests); absolute + values will differ over a network, but the *relative* discrepancies that + constitute FINDING-2 and FINDING-5 are structural (different work factors / + short-circuit paths) and will persist. +- No load, fuzzing, or dependency-CVE (SCA) scanning was performed; recommend + adding `govulncheck` (Go) and `npm audit`/OWASP Dependency-Check (frontend) + to CI as a follow-up. +- Scope was the application. Host hardening, PostgreSQL configuration + (`pg_hba.conf`, listen addresses), and OS patching were out of scope. +``` diff --git a/security/vapt-report.html b/security/vapt-report.html new file mode 100644 index 0000000..a15423c --- /dev/null +++ b/security/vapt-report.html @@ -0,0 +1,364 @@ + + + + + +TravelHub — Security Assessment Report + + + +
    + +
    +
    Security assessment · Whitebox VAPT
    +

    TravelHub

    +

    Vulnerability Assessment & Penetration Test · Authorised whitebox engagement
    + Multi-tenant flight booking — Go API, Angular SPA, PostgreSQL · 12 September 2026

    + +
    + ✓ + + Core access controls held; findings are operational and defence-in-depth
    + Tenant isolation, SQL parameterisation, mass-assignment and CORS defences all resisted direct attack. + 2 High, 5 Medium, 3 Low and 1 Informational finding were identified, chiefly around + credential handling, least privilege and transport hardening. +
    +
    + +
    +
    Engagement
    Whitebox (source-assisted)
    +
    Targets
    API :8080 · SPA :4200 · PostgreSQL 17
    +
    Findings
    2 High · 5 Medium · 3 Low · 1 Info
    +
    Critical / RCE (pre-auth)
    None
    +
    Method
    Static review + dynamic testing
    +
    Data handling
    Dev DB only; demo data restored
    +
    +
    + + +
    +
    1

    Executive summary

    +

    + TravelHub is architecturally sound on the issues that most often sink a multi-tenant + application. Tenant isolation held under direct testing, SQL is parameterised throughout, the + JSON decoder rejects unknown fields, request bodies are size-capped, and the Angular client + escapes interpolated data by default. The weaknesses found are operational and + defence-in-depth, not broken access control. +

    +

    + The two most important are a database superuser connection, which turns any + application compromise into total database control and nullifies the application-layer tenant + isolation, and a timing side-channel that backfired: the mitigation intended to + hide which usernames exist actually makes them more distinguishable. None of the + findings is a remotely exploitable pre-authentication breach on the current localhost + deployment; several become serious the moment the app is exposed beyond localhost. +

    + + + + + + + + + + + + + + + + +
    #SeverityFinding
    1HighApplication connects to PostgreSQL as a superuser (no least privilege)
    2HighUsername enumeration via a timing oracle — equalisation mitigation is miscalibrated and inverts
    3MediumBcrypt cost factor 6, below the OWASP-recommended minimum of 10
    4MediumNo rate limiting or account lockout on authentication
    5MediumTenant enumeration via timing (unknown tenant short-circuits before password check)
    6MediumNo HTTP security headers (HSTS, nosniff, frame-options, CSP…)
    7MediumDatabase connections run in plaintext (sslmode=disable)
    8LowReal database password committed in config_test.go
    9LowCredentials held reversibly in localStorage and replayed each request
    10LowAPI stores markup verbatim — no server-side output encoding (defence-in-depth)
    11InfoRequireAdmin middleware wired to no route; role model unenforced
    +
    + + +
    +
    2

    Controls that resisted attack

    +

    These were actively attacked and held. Recorded so the report is a fair account, not only a list of faults.

    + + + + + + + + + + + + + +
    ControlTest performedResult
    SQL injection' OR '1'='1, '; DROP TABLE bookings;--, UNION in search paramsParameterised
    ORDER BY injectionsort=(SELECT 1), sort=price;DROPWhitelisted
    Mass assignmentPOST booking with status, totalAmount, bookingReference, tenantIdRejected 400
    Cross-tenant read (IDOR)Fetch another agency's booking / flight by id404 not-found
    Cross-tenant credentialsSkyline password against Globetrotter tenant401
    Large-body DoS3 MB request body400 — 1MB cap
    CORSReflect Origin: evil.example.comNot reflected
    Error verbosityMalformed UUID / JSONGeneric
    Reflected XSSAngular template sinksAuto-escaped
    +
    + + +
    +
    3

    Detailed findings

    + +
    +

    FINDING-1 Application connects to PostgreSQL as a superuser High

    +
    CWE-250 Execution with Unnecessary Privileges · OWASP A04:2021 Insecure Design
    +

    Evidence

    +

    The application connects with the backend/.env credentials (DB_USER=postgres), which is the cluster superuser:

    +
    rolname  | rolsuper | rolcreatedb | rolcreaterole | rolbypassrls
    +postgres |    t     |      t      |       t       |      t
    +

    Impact

    +

    Tenant isolation is enforced entirely in application code (every query filters tenant_id), so the DB connection is the last line of defence — and it has none. Any query-control flaw becomes full read/write/DDL over every tenant, plus COPY … TO PROGRAM command execution and rolbypassrls defeating any future row-level security. The superuser connection silently converts application bugs into total compromise.

    +

    Recommendation

    +

    Connect as a least-privilege role; keep migrations under a separate higher-privilege role the service never uses.

    +
    CREATE ROLE travelapp LOGIN PASSWORD '…';
    +GRANT CONNECT ON DATABASE travelhub TO travelapp;
    +GRANT USAGE ON SCHEMA public TO travelapp;
    +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO travelapp;
    +-- NOT: SUPERUSER, CREATEROLE, CREATEDB, TRUNCATE, DDL
    +
    +
    + +
    +

    FINDING-2 Username enumeration via an inverted timing oracle High

    +
    CWE-208 Observable Timing Discrepancy · CWE-203 Observable Discrepancy
    +

    Evidence

    +

    The auth middleware runs a dummy bcrypt comparison for unknown users to equalise timing — a correct instinct — but the dummy hash is cost 10 while the real stored hashes are cost 6. Verifying a cost-10 hash costs ~16× a cost-6 hash, so the "user does not exist" path is far slower. Measured (mean of 30):

    +
    valid user + wrong password : 5.1 ms
    +unknown user, valid tenant  : 47.3 ms   ← ~9x slower
    +

    Impact

    +

    An unauthenticated attacker distinguishes valid from invalid usernames with a single request each, at a ~9× margin needing no statistics — enumerating the tenant's user directory for phishing or focused brute force. The mitigation did not merely fail; it created a stronger oracle than doing nothing, because the two branches now use different work factors.

    +

    Recommendation

    +

    Make the dummy hash's cost identical to the real one and pin both. Better: verify against a per-deployment constant hash generated at startup with the exact configured cost, so the two branches can never drift apart.

    +
    + +
    +

    FINDING-3 Bcrypt cost factor below recommended minimum Medium

    +
    CWE-916 Password Hash With Insufficient Computational Effort
    +

    Evidence

    +

    All stored hashes carry the $2a$06$ prefix (cost 6); 002_seed.sql relies on pgcrypto gen_salt('bf'), whose default is 6. The test helper uses bcrypt.MinCost (4), so production hashing strength is never exercised by the suite.

    +

    Impact

    +

    Cost 6 is 16× weaker than the OWASP cost-10 floor and 64× weaker than cost 12. Against an offline attacker who obtains the hashes (see Finding 1) it materially shortens crack time.

    +

    Recommendation

    +

    Hash at cost 12 in Go application code (bcrypt.GenerateFromPassword(pw, 12)) rather than in SQL, so the work factor lives with the app and stays consistent. Support rehash-on-login for old hashes.

    +
    + +
    +

    FINDING-4 No rate limiting or account lockout on authentication Medium

    +
    CWE-307 Improper Restriction of Excessive Authentication Attempts · A07:2021
    +

    Evidence

    +
    25 failed logins in 144 ms → 25x HTTP 401   (no 429, no delay, no lockout)
    +

    Impact

    +

    Online brute force and credential stuffing are unthrottled. Combined with Finding 2 (valid usernames handed to the attacker) and Finding 3 (weaker hashes), account takeover is realistic once exposed beyond localhost.

    +

    Recommendation

    +

    Add per-IP and per-account throttling with exponential backoff and temporary lockout, as a middleware in the existing Chain (token-bucket keyed on IP + X-Tenant + email). Return 429 with Retry-After; log repeated failures for alerting.

    +
    + +
    +

    FINDING-5 Tenant enumeration via timing Medium

    +
    CWE-204 Observable Response Discrepancy
    +

    Evidence

    +

    An unknown tenant short-circuits before any password check, so it is far faster than a known tenant with bad credentials:

    +
    unknown tenant         : 0.8 ms
    +valid tenant, wrong pw : 5.1 ms
    +

    Impact

    +

    Reveals which agency slugs exist — including suspended tenants, which the public GET /api/tenants endpoint deliberately hides. Lower impact than Finding 2 since active tenants are already public by design.

    +

    Recommendation

    +

    Perform the dummy bcrypt comparison on the unknown-tenant path too, so all failure modes cost the same. Fold into the Finding 2 fix.

    +
    + +
    +

    FINDING-6 Missing HTTP security headers Medium

    +
    CWE-693 Protection Mechanism Failure · A05:2021 Security Misconfiguration
    +

    Evidence

    +

    API responses carry only Content-Type, Content-Length, Date. Absent: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy, and Cache-Control on authenticated JSON. The SPA ships no CSP.

    +

    Impact

    +

    No MIME-sniffing protection, no clickjacking defence, no CSP to blunt a future XSS, and authenticated responses may be cached. Individually minor; collectively they remove several cheap layers.

    +

    Recommendation

    +

    Add a SecureHeaders middleware to the Chain: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store on authenticated responses, and HSTS once TLS terminates. Serve the SPA with a restrictive CSP.

    +
    + +
    +

    FINDING-7 Database connections run in plaintext Medium

    +
    CWE-319 Cleartext Transmission of Sensitive Information
    +

    Evidence

    +

    .env sets DB_SSLMODE=disable; the live connection reports PLAINTEXT via pg_stat_ssl.

    +

    Impact

    +

    Negligible on localhost. Once the database is on a separate host, credentials and PII (passenger names, passport numbers, contact details) cross the network unencrypted.

    +

    Recommendation

    +

    Use sslmode=verify-full with a pinned CA for any non-local database; keep disable only for loopback dev, and document that production must differ.

    +
    + +
    +

    FINDING-8 Real database password committed in test source Low

    +
    CWE-798 Use of Hard-coded Credentials
    +

    Evidence

    +

    config_test.go contains the actual development DB password as a test literal for the DSN-escaping test.

    +

    Impact

    +

    Low today (dev password, not yet a git repo), but it will enter git history on first push and persist forever, especially damaging if the password is reused. Secrets in fixtures are a common leak vector.

    +

    Recommendation

    +

    Use a synthetic password that still exercises escaping (e.g. p@ss:w/rd?#%). Add a secret-scanning pre-commit hook (gitleaks) before the first commit.

    +
    + +
    +

    FINDING-9 Credentials stored reversibly client-side and replayed Low

    +
    CWE-522 Insufficiently Protected Credentials · CWE-312 Cleartext Storage
    +

    Evidence

    +

    auth.service.ts stores btoa(email:password) in localStorage and replays it on every request. base64 is encoding, not encryption — the plaintext password is trivially recoverable.

    +

    Impact

    +

    Inherent to HTTP Basic auth (the README flags it). Concrete risk: any XSS or compromised SPA dependency reads the reusable password from localStorage — worse than a short-lived token — and it travels on every request.

    +

    Recommendation

    +

    As the README foresees, move to short-lived tokens with refresh in an HttpOnly; Secure; SameSite cookie. Auth logic is already isolated to two backend packages and the Angular service, so the change is contained.

    +
    + +
    +

    FINDING-10 No server-side output encoding of stored data Low

    +
    CWE-79 Stored XSS sink (currently unreachable)
    +

    Evidence

    +

    A passenger created with firstName = <script>alert(document.cookie)</script> is stored and returned verbatim by the API.

    +

    Impact

    +

    Not currently exploitable — the only consumer is Angular, which output-encodes, and no innerHTML sink exists. Latent: a future consumer rendering this as HTML (email, PDF ticket, admin tool, export) would execute it.

    +

    Recommendation

    +

    Treat the API as the trust boundary: normalise/validate free-text on input and ensure any non-Angular renderer encodes for its context. Do not rely solely on client escaping.

    +
    + +
    +

    FINDING-11 Role model implemented but unenforced Info

    +
    CWE-1220 Insufficient Granularity of Access Control (latent)
    +

    Evidence

    +

    RequireAdmin exists and is unit-tested, and users carry admin/agent roles, but router.go gates no route with it — every authenticated user reaches every endpoint.

    +

    Impact

    +

    None today (no admin-only functionality). Flagged because the moment one is added it must be remembered to attach RequireAdmin, or it will be silently open to all agents — a broken-access-control regression waiting to happen.

    +

    Recommendation

    +

    Gate admin functionality explicitly when introduced and add a test asserting an agent receives 403, mirroring the existing TestRequireAdmin. Prefer a default-deny posture for new routes.

    +
    +
    + + +
    +
    4

    Remediation priority

    + + + + + + + +
    PriorityFindingsRationale
    Before any non-local deployment1, 2, 4, 6, 7These turn from low-risk to serious the instant the app leaves localhost
    Soon3, 5, 8Cheap fixes that raise the cost of the attack paths above
    As the design evolves9, 10, 11Tie to the planned auth change and to new features
    +

    + A single focused change set closes most high/medium items: a least-privilege DB role (1); + SecureHeaders + rate-limiting middleware on the existing Chain (4, 6); + aligned bcrypt cost with hashing moved into Go (2, 3, 5); and sslmode flipped for + non-local (7). +

    +
    + + +
    +
    5

    Methodology & limitations

    +
      +
    • Testing was against a single-node localhost deployment with seeded data. A production + topology behind a reverse proxy / WAF / TLS terminator may already mitigate some + network-layer findings (6, 7) — verify against the real deployment.
    • +
    • Timing was measured over loopback (mean of 30 requests); absolute values differ over a + network, but the relative discrepancies behind Findings 2 and 5 are structural + (different work factors / short-circuit paths) and persist.
    • +
    • No load, fuzzing, or dependency-CVE (SCA) scanning was performed. Recommend adding + govulncheck and npm audit / OWASP Dependency-Check to CI as a follow-up.
    • +
    • Scope was the application. Host hardening, PostgreSQL pg_hba.conf / listen + configuration, and OS patching were out of scope.
    • +
    +
    + + +
    + +